Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ type AppsClient interface {
GetPresignedS3PostParams(ctx context.Context, token string, appID string) (GenerateS3PresignedPostResult, error)
Host() string
Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error)
IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error)
RequestAppApproval(ctx context.Context, token string, appID string, teamID string, reason string, scopes string, outgoingDomains []string) (AppsApprovalsRequestsCreateResult, error)
SetHost(host string)
UninstallApp(ctx context.Context, token string, appID, teamID string) error
Expand Down
16 changes: 14 additions & 2 deletions internal/api/icon.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import (

const (
appIconMethod = "apps.hosted.icon"
// AppIconSetMethod is the API method for setting app icons for non-hosted apps.
AppIconSetMethod = "apps.icon.set"
Comment thread
srtaalej marked this conversation as resolved.
Outdated
)

// IconResult details to be saved
Expand All @@ -48,6 +50,16 @@ type iconResponse struct {

// Icon updates a Slack App's icon
Comment thread
srtaalej marked this conversation as resolved.
Outdated
func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) {
return c.uploadIcon(ctx, fs, token, appID, iconFilePath, appIconMethod, "file")
}

// IconSet sets a Slack App's icon using the apps.icon.set API method.
func (c *Client) IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) {
return c.uploadIcon(ctx, fs, token, appID, iconFilePath, AppIconSetMethod, "icon")
}

// uploadIcon uploads an icon to the given API method.
func (c *Client) uploadIcon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath, apiMethod, fileFieldName string) (IconResult, error) {
var (
iconBytes []byte
err error
Expand Down Expand Up @@ -81,7 +93,7 @@ func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePa

var part io.Writer
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, "file", iconStat.Name()))
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fileFieldName, iconStat.Name()))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fileFieldName, iconStat.Name()))
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, iconStat.Name()))

🌚 suggestion: Let's revert the addition of fileFieldName here and in arguments too if this is static?

h.Set("Content-Type", http.DetectContentType(iconBytes))
part, err = writer.CreatePart(h)
if err != nil {
Expand All @@ -101,7 +113,7 @@ func (c *Client) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePa
writer.Close()

var sURL *url.URL
sURL, err = url.Parse(c.host + "/api/" + appIconMethod)
sURL, err = url.Parse(c.host + "/api/" + apiMethod)
if err != nil {
return IconResult{}, err
}
Expand Down
7 changes: 6 additions & 1 deletion internal/api/icon_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import (
)

func (m *APIMock) Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) {
args := m.Called(ctx, fs, token, iconFilePath)
args := m.Called(ctx, fs, token, appID, iconFilePath)
return args.Get(0).(IconResult), args.Error(1)
}

func (m *APIMock) IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) {
args := m.Called(ctx, fs, token, appID, iconFilePath)
return args.Get(0).(IconResult), args.Error(1)
}
72 changes: 72 additions & 0 deletions internal/api/icon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,78 @@ func TestClient_IconErrorWrongFile(t *testing.T) {
require.Contains(t, err.Error(), "unknown format")
}

func TestClient_IconSetErrorIfMissingArgs(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fs := afero.NewMemMapFs()
c, teardown := NewFakeClient(t, FakeClientParams{
ExpectedMethod: AppIconSetMethod,
})
defer teardown()
_, err := c.IconSet(ctx, fs, "token", "", "")
require.Error(t, err)
require.Contains(t, err.Error(), "missing required args")
}

func TestClient_IconSetErrorNoFile(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fs := afero.NewMemMapFs()
c, teardown := NewFakeClient(t, FakeClientParams{
ExpectedMethod: AppIconSetMethod,
})
defer teardown()
_, err := c.IconSet(ctx, fs, "token", "12345", imgFile)
require.Error(t, err)
require.Contains(t, err.Error(), "file does not exist")
}

func TestClient_IconSetErrorResponse(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fs := afero.NewMemMapFs()

myimage := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{100, 100}})
for x := range 100 {
for y := range 100 {
c := color.RGBA{uint8(rand.Intn(255)), uint8(rand.Intn(255)), uint8(rand.Intn(255)), 255}
myimage.Set(x, y, c)
}
}
myfile, _ := fs.Create(imgFile)
err := png.Encode(myfile, myimage)
require.NoError(t, err)
c, teardown := NewFakeClient(t, FakeClientParams{
ExpectedMethod: AppIconSetMethod,
Response: `{"ok":false,"error":"invalid_app"}`,
})
defer teardown()
_, err = c.IconSet(ctx, fs, "token", "12345", imgFile)
require.Error(t, err)
require.Contains(t, err.Error(), "invalid_app")
}

func TestClient_IconSetSuccess(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fs := afero.NewMemMapFs()

myimage := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{100, 100}})

for x := range 100 {
for y := range 100 {
c := color.RGBA{uint8(rand.Intn(255)), uint8(rand.Intn(255)), uint8(rand.Intn(255)), 255}
myimage.Set(x, y, c)
}
}
myfile, _ := fs.Create(imgFile)
err := png.Encode(myfile, myimage)
require.NoError(t, err)
c, teardown := NewFakeClient(t, FakeClientParams{
ExpectedMethod: AppIconSetMethod,
Response: `{"ok":true}`,
})
defer teardown()
_, err = c.IconSet(ctx, fs, "token", "12345", imgFile)
require.NoError(t, err)
}

func TestClient_IconSuccess(t *testing.T) {
ctx := slackcontext.MockContext(t.Context())
fs := afero.NewMemMapFs()
Expand Down
4 changes: 4 additions & 0 deletions internal/experiment/experiment.go
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ const (
// Sandboxes experiment lets users who have joined the Slack Developer Program use the CLI to manage their sandboxes.
Sandboxes Experiment = "sandboxes"

// SetIcon experiment enables icon upload for non-hosted apps.
SetIcon Experiment = "set-icon"

// Templates experiment brings more agent templates to the create command.
Templates Experiment = "templates"
)
Expand All @@ -49,6 +52,7 @@ var AllExperiments = []Experiment{
Lipgloss,
Placeholder,
Sandboxes,
SetIcon,
Templates,
}

Expand Down
44 changes: 20 additions & 24 deletions internal/pkg/apps/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/opentracing/opentracing-go"
"github.com/slackapi/slack-cli/internal/api"
"github.com/slackapi/slack-cli/internal/config"
"github.com/slackapi/slack-cli/internal/experiment"
"github.com/slackapi/slack-cli/internal/pkg/manifest"
"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/shared/types"
Expand Down Expand Up @@ -521,30 +522,25 @@ func InstallLocalApp(ctx context.Context, clients *shared.ClientFactory, orgGran
return app, result, installState, err
}

//
// TODO: Currently, cannot update the icon if app is not hosted.
//
// upload icon, default to icon.png
// var iconPath = slackYaml.Icon
// if iconPath == "" {
// if _, err := os.Stat("icon.png"); !os.IsNotExist(err) {
// iconPath = "icon.png"
// }
// }
// if iconPath != "" {
// clients.IO.PrintDebug(ctx, "uploading icon")
// err = updateIcon(ctx, clients, iconPath, env.AppID, token)
// if err != nil {
// clients.IO.PrintError(ctx, "An error occurred updating the Icon", err)
// }
// // Save a md5 hash of the icon in environments.yaml
// var iconHash string
// iconHash, err = getIconHash(iconPath)
// if err != nil {
// return env, api.DeveloperAppInstallResult{}, err
// }
// env.IconHash = iconHash
// }
// upload icon for non-hosted apps (gated behind set-icon experiment)
if clients.Config.WithExperimentOn(experiment.SetIcon) {
var iconPath = slackManifest.Icon
if iconPath == "" {
if _, err := os.Stat("icon.png"); !os.IsNotExist(err) {
iconPath = "icon.png"
}
}
if iconPath != "" {
clients.IO.PrintDebug(ctx, "uploading icon")
_, iconErr := clients.API().IconSet(ctx, clients.Fs, token, app.AppID, iconPath)
Comment on lines +534 to +535
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔬 question: Could we match debug log outputs for the apps.icon.set method here? Right now I find this in verbose:

uploading icon

While other methods include various request and response details that are useful in debugging. I think edges in image upload might make verbose logging most important here!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this is a gap in both apps.icon.set and apps.hosted.icon I'm going to leave this for a followup PR 🫡

if iconErr != nil {
clients.IO.PrintDebug(ctx, "icon error: %s", iconErr)
_, _ = clients.IO.WriteOut().Write([]byte(style.SectionSecondaryf("Error updating app icon: %s", iconErr)))
} else {
_, _ = clients.IO.WriteOut().Write([]byte(style.SectionSecondaryf("Updated app icon: %s", iconPath)))
}
}
}

// update config with latest yaml hash
// env.Hash = slackYaml.Hash
Expand Down
Loading