detached 243a5c1

fix(cli): preserve deploy error chains

commit
243a5c1bd192710f76a753f680b8c1ef502f685b
author
brice · · 2026-04-06 14:28 UTC
parent
f231d9c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +49 -15
M internal/adapters/in/cli/deploy.go +11 -1
@@ -15,6 +15,8 @@ type deployer interface {
1515 Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)
1616 }
1717
18+var sendDeploySignal = app.SendDeploySignal
19+
1820 // newDeployCmd creates the deploy command.
1921 func newDeployCmd() *cobra.Command {
2022 var jsonOut bool
@@ -50,8 +52,16 @@ func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDoma
5052 result, err := deployer.Deploy(ctx, deployDomain)
5153 if err != nil {
5254 if isRemote && shouldFallbackToLocal(err) {
53- domain, localErr := app.SendDeploySignal(deployDomain)
55+ domain, localErr := sendDeploySignal(deployDomain)
5456 if localErr == nil {
57+ warning := fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err)
58+ if jsonOut {
59+ return writeJSON(out, map[string]string{
60+ "warning": warning,
61+ "domain": domain,
62+ "status": "success",
63+ })
64+ }
5565 if writeErr := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err))); writeErr != nil {
5666 return writeErr
5767 }
@@ -15,6 +15,8 @@ type deployer interface {
15 Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)15 Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error)
16 }16 }
17 17
18+var sendDeploySignal = app.SendDeploySignal
19+
18 // newDeployCmd creates the deploy command.20 // newDeployCmd creates the deploy command.
19 func newDeployCmd() *cobra.Command {21 func newDeployCmd() *cobra.Command {
20 var jsonOut bool22 var jsonOut bool
@@ -50,8 +52,16 @@ func runDeploy(ctx context.Context, deployer deployer, isRemote bool, deployDoma
50 result, err := deployer.Deploy(ctx, deployDomain)52 result, err := deployer.Deploy(ctx, deployDomain)
51 if err != nil {53 if err != nil {
52 if isRemote && shouldFallbackToLocal(err) {54 if isRemote && shouldFallbackToLocal(err) {
53- domain, localErr := app.SendDeploySignal(deployDomain)55+ domain, localErr := sendDeploySignal(deployDomain)
54 if localErr == nil {56 if localErr == nil {
57+ warning := fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err)
58+ if jsonOut {
59+ return writeJSON(out, map[string]string{
60+ "warning": warning,
61+ "domain": domain,
62+ "status": "success",
63+ })
64+ }
55 if writeErr := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err))); writeErr != nil {65 if writeErr := cliWriteLine(out, cliRenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err))); writeErr != nil {
56 return writeErr66 return writeErr
57 }67 }
M internal/adapters/in/cli/deploy_error.go +2 -2
@@ -16,12 +16,12 @@ var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
1616 func formatDeployFailure(err error) error {
1717 var deployErr *domain.DeployFailureError
1818 if errors.As(err, &deployErr) {
19- return errors.New(renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs))
19+ return fmt.Errorf("%s: %w", renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs), deployErr)
2020 }
2121
2222 var httpErr *remote.HTTPError
2323 if errors.As(err, &httpErr) && (httpErr.Structured || httpErr.Cause != "" || httpErr.Hint != "" || len(httpErr.Logs) > 0) {
24- return errors.New(renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs))
24+ return fmt.Errorf("%s: %w", renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs), httpErr)
2525 }
2626
2727 return fmt.Errorf("failed to deploy: %w", err)
@@ -16,12 +16,12 @@ var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
16 func formatDeployFailure(err error) error {16 func formatDeployFailure(err error) error {
17 var deployErr *domain.DeployFailureError17 var deployErr *domain.DeployFailureError
18 if errors.As(err, &deployErr) {18 if errors.As(err, &deployErr) {
19- return errors.New(renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs))19+ return fmt.Errorf("%s: %w", renderDeployFailure(deployErr.Summary, deployErr.Cause, deployErr.Hint, deployErr.Logs), deployErr)
20 }20 }
21 21
22 var httpErr *remote.HTTPError22 var httpErr *remote.HTTPError
23 if errors.As(err, &httpErr) && (httpErr.Structured || httpErr.Cause != "" || httpErr.Hint != "" || len(httpErr.Logs) > 0) {23 if errors.As(err, &httpErr) && (httpErr.Structured || httpErr.Cause != "" || httpErr.Hint != "" || len(httpErr.Logs) > 0) {
24- return errors.New(renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs))24+ return fmt.Errorf("%s: %w", renderDeployFailure(httpErr.Body, httpErr.Cause, httpErr.Hint, httpErr.Logs), httpErr)
25 }25 }
26 26
27 return fmt.Errorf("failed to deploy: %w", err)27 return fmt.Errorf("failed to deploy: %w", err)
M internal/adapters/in/cli/deploy_error_test.go +16 -9
@@ -12,7 +12,7 @@ import (
1212 )
1313
1414 func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) {
15- err := formatDeployFailure(&remote.HTTPError{
15+ root := &remote.HTTPError{
1616 StatusCode: 500,
1717 Status: "500 Internal Server Error",
1818 Body: "failed to deploy",
@@ -22,14 +22,16 @@ func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) {
2222 "booting app",
2323 "connection refused",
2424 },
25- })
25+ }
26+ err := formatDeployFailure(root)
2627
2728 require.Error(t, err)
28- assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error())
29+ assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused")
30+ assert.ErrorIs(t, err, root)
2931 }
3032
3133 func TestFormatDeployFailure_LocalTypedError(t *testing.T) {
32- err := formatDeployFailure(&domain.DeployFailureError{
34+ root := &domain.DeployFailureError{
3335 Summary: "failed to deploy",
3436 Cause: "health check failed",
3537 Hint: "check DATABASE_URL",
@@ -37,10 +39,12 @@ func TestFormatDeployFailure_LocalTypedError(t *testing.T) {
3739 "booting app",
3840 "connection refused",
3941 },
40- })
42+ }
43+ err := formatDeployFailure(root)
4144
4245 require.Error(t, err)
43- assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error())
46+ assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused")
47+ assert.ErrorIs(t, err, root)
4448 }
4549
4650 func TestFormatDeployFailure_GenericFallback(t *testing.T) {
@@ -53,12 +57,15 @@ func TestFormatDeployFailure_GenericFallback(t *testing.T) {
5357 }
5458
5559 func TestFormatDeployFailure_SkipsEmptySanitizedLogsSection(t *testing.T) {
56- err := formatDeployFailure(&domain.DeployFailureError{
60+ root := &domain.DeployFailureError{
5761 Summary: "failed to deploy",
5862 Cause: "health check failed",
5963 Logs: []string{"\x1b[31m\x1b[0m", "\n\r\t"},
60- })
64+ }
65+ err := formatDeployFailure(root)
6166
6267 require.Error(t, err)
63- assert.Equal(t, "failed to deploy\nCause: health check failed", err.Error())
68+ assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed")
69+ assert.NotContains(t, err.Error(), "Recent container logs:")
70+ assert.ErrorIs(t, err, root)
6471 }
@@ -12,7 +12,7 @@ import (
12 )12 )
13 13
14 func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) {14 func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) {
15- err := formatDeployFailure(&remote.HTTPError{15+ root := &remote.HTTPError{
16 StatusCode: 500,16 StatusCode: 500,
17 Status: "500 Internal Server Error",17 Status: "500 Internal Server Error",
18 Body: "failed to deploy",18 Body: "failed to deploy",
@@ -22,14 +22,16 @@ func TestFormatDeployFailure_RemoteStructuredError(t *testing.T) {
22 "booting app",22 "booting app",
23 "connection refused",23 "connection refused",
24 },24 },
25- })25+ }
26+ err := formatDeployFailure(root)
26 27
27 require.Error(t, err)28 require.Error(t, err)
28- assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error())29+ assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused")
30+ assert.ErrorIs(t, err, root)
29 }31 }
30 32
31 func TestFormatDeployFailure_LocalTypedError(t *testing.T) {33 func TestFormatDeployFailure_LocalTypedError(t *testing.T) {
32- err := formatDeployFailure(&domain.DeployFailureError{34+ root := &domain.DeployFailureError{
33 Summary: "failed to deploy",35 Summary: "failed to deploy",
34 Cause: "health check failed",36 Cause: "health check failed",
35 Hint: "check DATABASE_URL",37 Hint: "check DATABASE_URL",
@@ -37,10 +39,12 @@ func TestFormatDeployFailure_LocalTypedError(t *testing.T) {
37 "booting app",39 "booting app",
38 "connection refused",40 "connection refused",
39 },41 },
40- })42+ }
43+ err := formatDeployFailure(root)
41 44
42 require.Error(t, err)45 require.Error(t, err)
43- assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error())46+ assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused")
47+ assert.ErrorIs(t, err, root)
44 }48 }
45 49
46 func TestFormatDeployFailure_GenericFallback(t *testing.T) {50 func TestFormatDeployFailure_GenericFallback(t *testing.T) {
@@ -53,12 +57,15 @@ func TestFormatDeployFailure_GenericFallback(t *testing.T) {
53 }57 }
54 58
55 func TestFormatDeployFailure_SkipsEmptySanitizedLogsSection(t *testing.T) {59 func TestFormatDeployFailure_SkipsEmptySanitizedLogsSection(t *testing.T) {
56- err := formatDeployFailure(&domain.DeployFailureError{60+ root := &domain.DeployFailureError{
57 Summary: "failed to deploy",61 Summary: "failed to deploy",
58 Cause: "health check failed",62 Cause: "health check failed",
59 Logs: []string{"\x1b[31m\x1b[0m", "\n\r\t"},63 Logs: []string{"\x1b[31m\x1b[0m", "\n\r\t"},
60- })64+ }
65+ err := formatDeployFailure(root)
61 66
62 require.Error(t, err)67 require.Error(t, err)
63- assert.Equal(t, "failed to deploy\nCause: health check failed", err.Error())68+ assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed")
69+ assert.NotContains(t, err.Error(), "Recent container logs:")
70+ assert.ErrorIs(t, err, root)
64 }71 }
M internal/adapters/in/cli/deploy_test.go +20 -3
@@ -3,6 +3,7 @@ package cli
33 import (
44 "bytes"
55 "context"
6+ "errors"
67 "testing"
78
89 "github.com/stretchr/testify/assert"
@@ -22,15 +23,17 @@ func (d *deployTestDeployer) Deploy(context.Context, string) (*remote.DeployResu
2223 }
2324
2425 func TestRunDeploy_LocalTypedErrorUsesDeployFormatter(t *testing.T) {
25- err := runDeploy(context.Background(), &deployTestDeployer{err: &domain.DeployFailureError{
26+ root := &domain.DeployFailureError{
2627 Summary: "failed to deploy",
2728 Cause: "health check failed",
2829 Hint: "check DATABASE_URL",
2930 Logs: []string{"booting app", "connection refused"},
30- }}, false, "app.example.com", &bytes.Buffer{}, false)
31+ }
32+ err := runDeploy(context.Background(), &deployTestDeployer{err: root}, false, "app.example.com", &bytes.Buffer{}, false)
3133
3234 require.Error(t, err)
33- assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error())
35+ assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused")
36+ assert.ErrorIs(t, err, root)
3437 }
3538
3639 func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) {
@@ -45,3 +48,17 @@ func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) {
4548 assert.Contains(t, out.String(), "Deployed actual.example.com (container: 1234567890ab)")
4649 assert.NotContains(t, out.String(), "requested.example.com")
4750 }
51+
52+func TestRunDeploy_RemoteFallbackUsesJSONWhenRequested(t *testing.T) {
53+ originalSendDeploySignal := sendDeploySignal
54+ sendDeploySignal = func(string) (string, error) {
55+ return "app.example.com", nil
56+ }
57+ defer func() { sendDeploySignal = originalSendDeploySignal }()
58+
59+ var out bytes.Buffer
60+ err := runDeploy(context.Background(), &deployTestDeployer{err: errors.New("503 Service Unavailable: Registry Unavailable")}, true, "app.example.com", &out, true)
61+
62+ require.NoError(t, err)
63+ assert.JSONEq(t, `{"domain":"app.example.com","status":"success","warning":"Remote deploy failed (503 Service Unavailable: Registry Unavailable), used local signal fallback"}`, out.String())
64+}
@@ -3,6 +3,7 @@ package cli
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "errors"
6 "testing"7 "testing"
7 8
8 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
@@ -22,15 +23,17 @@ func (d *deployTestDeployer) Deploy(context.Context, string) (*remote.DeployResu
22 }23 }
23 24
24 func TestRunDeploy_LocalTypedErrorUsesDeployFormatter(t *testing.T) {25 func TestRunDeploy_LocalTypedErrorUsesDeployFormatter(t *testing.T) {
25- err := runDeploy(context.Background(), &deployTestDeployer{err: &domain.DeployFailureError{26+ root := &domain.DeployFailureError{
26 Summary: "failed to deploy",27 Summary: "failed to deploy",
27 Cause: "health check failed",28 Cause: "health check failed",
28 Hint: "check DATABASE_URL",29 Hint: "check DATABASE_URL",
29 Logs: []string{"booting app", "connection refused"},30 Logs: []string{"booting app", "connection refused"},
30- }}, false, "app.example.com", &bytes.Buffer{}, false)31+ }
32+ err := runDeploy(context.Background(), &deployTestDeployer{err: root}, false, "app.example.com", &bytes.Buffer{}, false)
31 33
32 require.Error(t, err)34 require.Error(t, err)
33- assert.Equal(t, "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused", err.Error())35+ assert.Contains(t, err.Error(), "failed to deploy\nCause: health check failed\nHint: check DATABASE_URL\n\nRecent container logs:\n booting app\n connection refused")
36+ assert.ErrorIs(t, err, root)
34 }37 }
35 38
36 func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) {39 func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) {
@@ -45,3 +48,17 @@ func TestRunDeploy_UsesResultDomainWhenPresent(t *testing.T) {
45 assert.Contains(t, out.String(), "Deployed actual.example.com (container: 1234567890ab)")48 assert.Contains(t, out.String(), "Deployed actual.example.com (container: 1234567890ab)")
46 assert.NotContains(t, out.String(), "requested.example.com")49 assert.NotContains(t, out.String(), "requested.example.com")
47 }50 }
51+
52+func TestRunDeploy_RemoteFallbackUsesJSONWhenRequested(t *testing.T) {
53+ originalSendDeploySignal := sendDeploySignal
54+ sendDeploySignal = func(string) (string, error) {
55+ return "app.example.com", nil
56+ }
57+ defer func() { sendDeploySignal = originalSendDeploySignal }()
58+
59+ var out bytes.Buffer
60+ err := runDeploy(context.Background(), &deployTestDeployer{err: errors.New("503 Service Unavailable: Registry Unavailable")}, true, "app.example.com", &out, true)
61+
62+ require.NoError(t, err)
63+ assert.JSONEq(t, `{"domain":"app.example.com","status":"success","warning":"Remote deploy failed (503 Service Unavailable: Registry Unavailable), used local signal fallback"}`, out.String())
64+}

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