Repository: digitalocean/doctl
Pull request #1627 (2024-12-07 16:16:35)

Added support getting logs for an app by name.
I host [a bunch of apps](https://www.vestris.com/) on DO which is working really great. 

Trying to use the UX less. This change lists apps if you specify anything other than a UUID, I use this as `doctl apps logs slack-strava` vs. `doctl apps logs 4bf2666d-8fa9-4edd-8013-298ce3a8d52a` that I always have to get from the command line or UI.

Closes https://github.com/digitalocean/doctl/issues/1615.



Patch:
diff --git a/commands/apps.go b/commands/apps.go
index 6489f6bc..32c7153a 100644
--- a/commands/apps.go
+++ b/commands/apps.go
@@ -33,6 +33,7 @@ import (
 	"github.com/digitalocean/doctl/internal/apps"
 	"github.com/digitalocean/doctl/pkg/terminal"
 	"github.com/digitalocean/godo"
+	"github.com/google/uuid"
 	multierror "github.com/hashicorp/go-multierror"
 	"github.com/spf13/cobra"
 	"golang.org/x/sync/errgroup"
@@ -189,7 +190,7 @@ Only basic information is included with the text output format. For complete app
 	logs := CmdBuilder(
 		cmd,
 		RunAppsGetLogs,
-		"logs <app id> <component name (defaults to all components)>",
+		"logs <app name or id> <component name (defaults to all components)>",
 		"Retrieves logs",
 		`Retrieves component logs for a deployment of an app.
 
@@ -659,17 +660,24 @@ func RunAppsGetLogs(c *CmdConfig) error {
 	if err != nil {
 		return err
 	}
-	if deploymentID == "" {
-		app, err := c.Apps().Get(appID)
+
+	_, err = uuid.Parse(appID)
+	if err != nil || deploymentID == "" {
+		app, err := c.Apps().Find(appID)
 		if err != nil {
 			return err
 		}
-		if app.ActiveDeployment != nil {
-			deploymentID = app.ActiveDeployment.ID
-		} else if app.InProgressDeployment != nil {
-			deploymentID = app.InProgressDeployment.ID
-		} else {
-			return fmt.Errorf("unable to retrieve logs; no deployment found for app %s", appID)
+
+		appID = app.ID
+
+		if deploymentID == "" {
+			if app.ActiveDeployment != nil {
+				deploymentID = app.ActiveDeployment.ID
+			} else if app.InProgressDeployment != nil {
+				deploymentID = app.InProgressDeployment.ID
+			} else {
+				return fmt.Errorf("unable to retrieve logs; no deployment found for app %s", appID)
+			}
 		}
 	}
 
diff --git a/do/apps.go b/do/apps.go
index d5bc0a4a..f2b8516d 100644
--- a/do/apps.go
+++ b/do/apps.go
@@ -15,13 +15,16 @@ package do
 
 import (
 	"context"
+	"fmt"
 
 	"github.com/digitalocean/godo"
+	"github.com/google/uuid"
 )
 
 // AppsService is the interface that wraps godo AppsService.
 type AppsService interface {
 	Create(req *godo.AppCreateRequest) (*godo.App, error)
+	Find(appRef string) (*godo.App, error)
 	Get(appID string) (*godo.App, error)
 	List(withProjects bool) ([]*godo.App, error)
 	Update(appID string, req *godo.AppUpdateRequest) (*godo.App, error)
@@ -74,11 +77,32 @@ func (s *appsService) Create(req *godo.AppCreateRequest) (*godo.App, error) {
 	return app, nil
 }
 
+func (s *appsService) Find(appRef string) (*godo.App, error) {
+	_, err := uuid.Parse(appRef)
+	if err == nil {
+		return s.Get(appRef)
+	}
+
+	apps, err := s.List(false)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, app := range apps {
+		if app.Spec.Name == appRef {
+			return app, nil
+		}
+	}
+
+	return nil, fmt.Errorf("Cannot find app %s", appRef)
+}
+
 func (s *appsService) Get(appID string) (*godo.App, error) {
 	app, _, err := s.client.Apps.Get(s.ctx, appID)
 	if err != nil {
 		return nil, err
 	}
+
 	return app, nil
 }
 
diff --git a/do/mocks/AppsService.go b/do/mocks/AppsService.go
index f4fdd985..96494c5e 100644
--- a/do/mocks/AppsService.go
+++ b/do/mocks/AppsService.go
@@ -84,6 +84,21 @@ func (mr *MockAppsServiceMockRecorder) Delete(appID any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockAppsService)(nil).Delete), appID)
 }
 
+// Find mocks base method.
+func (m *MockAppsService) Find(appRef string) (*godo.App, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Find", appRef)
+	ret0, _ := ret[0].(*godo.App)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Find indicates an expected call of Find.
+func (mr *MockAppsServiceMockRecorder) Find(appRef any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Find", reflect.TypeOf((*MockAppsService)(nil).Find), appRef)
+}
+
 // Get mocks base method.
 func (m *MockAppsService) Get(appID string) (*godo.App, error) {
 	m.ctrl.T.Helper()


Test patch:
diff --git a/commands/apps_test.go b/commands/apps_test.go
index c4e705a0..7cce0275 100644
--- a/commands/apps_test.go
+++ b/commands/apps_test.go
@@ -560,6 +560,94 @@ func TestRunAppsGetLogs(t *testing.T) {
 	}
 }
 
+func TestRunAppsGetLogsWithAppName(t *testing.T) {
+	appName := "test-app"
+	component := "service"
+
+	testApp := &godo.App{
+		ID:   uuid.New().String(),
+		Spec: &testAppSpec,
+		ActiveDeployment: &godo.Deployment{
+			ID:   uuid.New().String(),
+			Spec: &testAppSpec,
+		},
+	}
+
+	types := map[string]godo.AppLogType{
+		"build":  godo.AppLogTypeBuild,
+		"deploy": godo.AppLogTypeDeploy,
+		"run":    godo.AppLogTypeRun,
+	}
+
+	for typeStr, logType := range types {
+		withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+			tm.apps.EXPECT().Find(appName).Times(1).Return(testApp, nil)
+			tm.apps.EXPECT().GetLogs(testApp.ID, testApp.ActiveDeployment.ID, component, logType, true, 1).Times(1).Return(&godo.AppLogs{LiveURL: "https://proxy-apps-prod-ams3-001.ondigitalocean.app/?token=aa-bb-11-cc-33"}, nil)
+			tm.listen.EXPECT().Listen(gomock.Any()).Times(1).Return(nil)
+
+			tc := config.Doit.(*doctl.TestConfig)
+			tc.ListenFn = func(url *url.URL, token string, schemaFunc listen.SchemaFunc, out io.Writer, in <-chan []byte) listen.ListenerService {
+				assert.Equal(t, "aa-bb-11-cc-33", token)
+				assert.Equal(t, "wss://proxy-apps-prod-ams3-001.ondigitalocean.app/?token=aa-bb-11-cc-33", url.String())
+				return tm.listen
+			}
+
+			config.Args = append(config.Args, appName, component)
+			config.Doit.Set(config.NS, doctl.ArgAppDeployment, "")
+			config.Doit.Set(config.NS, doctl.ArgAppLogType, typeStr)
+			config.Doit.Set(config.NS, doctl.ArgAppLogFollow, true)
+			config.Doit.Set(config.NS, doctl.ArgAppLogTail, 1)
+
+			err := RunAppsGetLogs(config)
+			require.NoError(t, err)
+		})
+	}
+}
+
+func TestRunAppsGetLogsWithAppNameAndDeploymentID(t *testing.T) {
+	appName := "test-app"
+	component := "service"
+
+	testApp := &godo.App{
+		ID:   uuid.New().String(),
+		Spec: &testAppSpec,
+		ActiveDeployment: &godo.Deployment{
+			ID:   uuid.New().String(),
+			Spec: &testAppSpec,
+		},
+	}
+
+	types := map[string]godo.AppLogType{
+		"build":  godo.AppLogTypeBuild,
+		"deploy": godo.AppLogTypeDeploy,
+		"run":    godo.AppLogTypeRun,
+	}
+
+	for typeStr, logType := range types {
+		withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+			tm.apps.EXPECT().Find(appName).Times(1).Return(testApp, nil)
+			tm.apps.EXPECT().GetLogs(testApp.ID, testApp.ActiveDeployment.ID, component, logType, true, 1).Times(1).Return(&godo.AppLogs{LiveURL: "https://proxy-apps-prod-ams3-001.ondigitalocean.app/?token=aa-bb-11-cc-33"}, nil)
+			tm.listen.EXPECT().Listen(gomock.Any()).Times(1).Return(nil)
+
+			tc := config.Doit.(*doctl.TestConfig)
+			tc.ListenFn = func(url *url.URL, token string, schemaFunc listen.SchemaFunc, out io.Writer, in <-chan []byte) listen.ListenerService {
+				assert.Equal(t, "aa-bb-11-cc-33", token)
+				assert.Equal(t, "wss://proxy-apps-prod-ams3-001.ondigitalocean.app/?token=aa-bb-11-cc-33", url.String())
+				return tm.listen
+			}
+
+			config.Args = append(config.Args, appName, component)
+			config.Doit.Set(config.NS, doctl.ArgAppDeployment, testApp.ActiveDeployment.ID)
+			config.Doit.Set(config.NS, doctl.ArgAppLogType, typeStr)
+			config.Doit.Set(config.NS, doctl.ArgAppLogFollow, true)
+			config.Doit.Set(config.NS, doctl.ArgAppLogTail, 1)
+
+			err := RunAppsGetLogs(config)
+			require.NoError(t, err)
+		})
+	}
+}
+
 func TestRunAppsConsole(t *testing.T) {
 	appID := uuid.New().String()
 	deploymentID := uuid.New().String()