Skip to content
1 change: 1 addition & 0 deletions cmd/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
cmd.AddCommand(NewDeleteCommand(clients))
cmd.AddCommand(NewLinkCommand(clients))
cmd.AddCommand(NewListCommand(clients))
cmd.AddCommand(NewRequestsCommand(clients))
Comment thread
AmyScript marked this conversation as resolved.
Outdated
cmd.AddCommand(NewSettingsCommand(clients))
cmd.AddCommand(NewUninstallCommand(clients))
cmd.AddCommand(NewUnlinkCommand(clients))
Expand Down
239 changes: 239 additions & 0 deletions cmd/app/requests.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
// Copyright 2022-2026 Salesforce, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package app

import (
"context"
"fmt"
"sort"
"strings"
"time"

"github.com/opentracing/opentracing-go"
"github.com/slackapi/slack-cli/internal/api"
"github.com/slackapi/slack-cli/internal/cmdutil"
"github.com/slackapi/slack-cli/internal/experiment"
"github.com/slackapi/slack-cli/internal/prompts"
"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/shared/types"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/slackapi/slack-cli/internal/style"
"github.com/spf13/cobra"
)

// requestsTimeFormat displays the moment a request changed
const requestsTimeFormat = "2006-01-02 15:04:05 Z07:00"

// Handle to a function used for testing
var requestsAppSelectPromptFunc = prompts.AppSelectPrompt

// Handle to a function used for testing
var requestsTeamSelectPromptFunc = prompts.PromptTeamSlackAuth

// Flags

Comment thread
AmyScript marked this conversation as resolved.
Outdated
type requestsCmdFlags struct {
workspaceIDs []string
}

var requestsFlags requestsCmdFlags

// NewRequestsCommand returns a new Cobra command
func NewRequestsCommand(clients *shared.ClientFactory) *cobra.Command {
cmd := &cobra.Command{
Use: "requests [flags]",

@mwbrooks mwbrooks Aug 25, 2026

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.

suggestion: After some thought, I'd like to suggest renaming this to approval (singular)

The reason is that app approval matches the API (apps.approvals.requests.list) and leaves room for slack app approval [create|cancel|list] later.

We've had requests for a cancel feature, so this opens the namespace for it.

The bare approval also matches the bare app → app list, trigger → trigger list, auth → auth list, and manifest → manifest list convention. So, if we expand the command with create | cancel | list subcommands, we've made decisions that allow for a smooth forward rollout.

The approvals command also matches the terminology used in our documentation, where as requests is more from the admin-perspective:

We should alias the plural approvals. The CLI usually has the canonical command as singular while aliasing the plural. Long ago, we mixed-and-matched singular/plural depending on the command and it was confusing for developers. So, we try to use singular when possible.

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.

Hey @mwbrooks, thanks for the thought you put into this, and for surfacing that there's been a request for a cancel feature.

I should have explained the design considerations for using app requests in the PR description. My thinking is that Slack's vocabulary splits by role rather than by feature: the person who wants the app requests it, and the admin approves it. This command runs as the requester, reading their own request status, so it sits on the request half.

That split shows up in the sources you linked, too. Both are written for the approver, and even there, the object is a request: the admin listing method is admin.apps.requests.list, the event is app_requested, the companion help article is "Manage app requests for your workspace".

On the requester side, request also shows up in our CLI and UI:

  • The CLI install flow: "Your request to install the app is pending", "Please submit a request to install or update your app" (internal/pkg/apps/install.go), and "Cancel the current request to install this app?" (internal/api/app.go).
  • On the app settings page under Install App, when approval is on, the button is Request to Install, and Slackbot follows up with "Your request to install [APP] on [WORKSPACE] has been sent for review."
  • The member-facing help article has a "Request app approval" section: https://slack.com/help/articles/202035138-Add-apps-to-your-Slack-workspace

Good point about leaving room for subcommands. The requester side verbs are create, cancel, and list, and those read naturally as app request cancel, our own enum comment describes that action as "the requester withdrawing their own request". Whereas app approval cancel sounds like revoking an approval someone granted. That's also why I left approval out of the aliases. If app request claims that name now, we can't give it to an admin side app approval later if we need it.

You're right that we should use the singular, so I've renamed it to app request with requests as the only alias. The command is hidden behind the app-approval-status experiment right now, so if you still feel strongly about it, I am happy to switch it if you'd rather go with approval. Let me know what you think!

Aliases: []string{"approval-requests", "approvals"},

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.

@mwbrooks here are the aliases I was talking about.

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.

suggestion: Assuming we go with app approval, alias approvals and remove the others. It's hard to remove an alias once we add it, but it's easy to add one in the future.

Suggested change
Aliases: []string{"approval-requests", "approvals"},
Aliases: []string{"approvals"},

Short: "Check requests to install the app",
Comment thread
AmyScript marked this conversation as resolved.
Outdated
Long: strings.Join([]string{
"Check the status of your most recent request to have the app approved for",
"install.",
"",
"Requests are searched on the team of the authenticated account. An account of",
"a workspace that belongs to an organization also searches that organization,",
"while an account of an organization searches the organization alone.",
"",
"Other workspaces of an organization can be searched with the --workspace-ids",
"flag.",
"",
"Searches are made with the credentials of an authenticated account chosen",
"with the --team flag or a prompt.",
"",
"Apps saved to a project are chosen with a prompt, but any app can be checked",
"by app ID with the --app flag, which does not require a project.",
}, "\n"),
Hidden: true,
Example: style.ExampleCommandsf([]style.ExampleCommand{
{Command: "app requests", Meaning: "Check requests to install an app"},
{Command: "app requests --app A0123456789", Meaning: "Check requests for an app outside a project"},
{Command: "app requests --workspace-ids T0123456789,T9876543210", Meaning: "Check requests on certain workspaces of an organization"},

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.

suggestion: Assuming we go with app approval:

Suggested change
{Command: "app requests", Meaning: "Check requests to install an app"},
{Command: "app requests --app A0123456789", Meaning: "Check requests for an app outside a project"},
{Command: "app requests --workspace-ids T0123456789,T9876543210", Meaning: "Check requests on certain workspaces of an organization"},
{Command: "app approval", Meaning: "Check requests to install an app"},
{Command: "app approval --app A0123456789", Meaning: "Check requests for an app outside a project"},
{Command: "app approval --workspace-ids T0123456789,T9876543210", Meaning: "Check requests on certain workspaces of an organization"},

}),
Args: cobra.NoArgs,
PreRunE: func(cmd *cobra.Command, args []string) error {
if !clients.Config.WithExperimentOn(experiment.AppApprovalStatus) {
return slackerror.New(slackerror.ErrExperimentRequired).
WithRemediation("Enable the %s experiment with %s",
style.Highlight(string(experiment.AppApprovalStatus)),
style.CommandText("--experiment app-approval-status"),
)
}
clients.Config.SetFlags(cmd)
// An app named by ID is checked without the apps of a project
if types.IsAppID(clients.Config.AppFlag) {
return nil
}
// Verify command is run in a project directory
if err := cmdutil.IsValidProjectDirectory(clients); err != nil {
invalid := slackerror.ToSlackError(err)
return invalid.WithRemediation("%s\n\nApps of other projects can be checked with %s",
invalid.Remediation,
style.CommandText("--app A0123456789"),
)
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return runRequestsCommand(cmd, clients)
},
}

cmd.Flags().StringSliceVar(&requestsFlags.workspaceIDs, "workspace-ids", nil, "also check these workspaces of an organization,\nwith a maximum of 50 workspaces")
Comment thread
AmyScript marked this conversation as resolved.
Outdated

return cmd
}

// runRequestsCommand will execute the requests command
func runRequestsCommand(cmd *cobra.Command, clients *shared.ClientFactory) error {
ctx := cmd.Context()
span, ctx := opentracing.StartSpanFromContext(ctx, "cmd.app.requests")

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.

suggestion: If we accept app approval then we should adjust all of our tracing.

Suggested change
span, ctx := opentracing.StartSpanFromContext(ctx, "cmd.app.requests")
span, ctx := opentracing.StartSpanFromContext(ctx, "cmd.app.approval")

defer span.Finish()

appID, token, err := requestsAppSelection(ctx, clients)
if err != nil {
return err
}

result, err := clients.API().ListAppApprovalRequests(ctx, token, appID, requestsFlags.workspaceIDs)
if err != nil {
return err
}

clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
Emoji: "lock",
Text: "App Requests",
Comment thread
AmyScript marked this conversation as resolved.
Outdated
Secondary: FormatRequestsSuccess(appID, result.Requests),
}))
return nil
}

// requestsAppSelection decides the app to check and a token of the app team.
//
// An app named by ID with the app flag is checked without a project so that
// apps missing from a project can be checked too. The team of that app is
// gathered from the authenticated accounts instead of the project apps.
func requestsAppSelection(ctx context.Context, clients *shared.ClientFactory) (appID string, token string, err error) {
if types.IsAppID(clients.Config.AppFlag) {
auth, err := requestsTeamSelectPromptFunc(ctx, clients, "Select an account to search with", nil)
if err != nil {
return "", "", err
}
if auth == nil || auth.Token == "" {
return "", "", slackerror.New(slackerror.ErrCredentialsNotFound)
}
return clients.Config.AppFlag, auth.Token, nil
}
selection, err := requestsAppSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps)
if err != nil {
return "", "", err
}
if selection.App.AppID == "" {
return "", "", slackerror.New(slackerror.ErrAppNotFound)
}
return selection.App.AppID, selection.Auth.Token, nil
}

// FormatRequestsSuccess formats the install request of each team for an app
func FormatRequestsSuccess(appID string, requests []api.AppsApprovalsRequest) (secondaryText []string) {
sort.Slice(requests, func(i, j int) bool {
Comment thread
AmyScript marked this conversation as resolved.
Outdated
return requests[i].TeamID < requests[j].TeamID
})
field := func(label string, value string) string {
return fmt.Sprintf(style.Indent(style.Secondary("%-13s %s")), label+":", value)
}
if appID != "" {
secondaryText = append(secondaryText, fmt.Sprintf(style.Bold("%-13s %s"), "App ID:", appID))
}
// Requests are gathered apart from the app to know when none were made
requestsText := []string{}
for _, request := range requests {
requestsText = append(requestsText, fmt.Sprintf(style.Bold("%s:"), request.TeamID))
requestsText = append(requestsText, field("Request ID", request.ID))
requestsText = append(requestsText, field("Status", formatRequestStatus(request.Status)))
requestsText = append(requestsText, field("Requested", formatRequestTime(request.DateCreated)))
if request.DateResolved > 0 {
requestsText = append(requestsText, field("Resolved", formatRequestTime(request.DateResolved)))
}
if request.CancelledBy != "" {
requestsText = append(requestsText, field("Cancelled by", formatRequestCancelledBy(request.CancelledBy)))
}
if request.CanSelfApprove {
requestsText = append(requestsText, style.Indent(style.Secondary("You can install this app without approval. Please cancel the request.")))
}
}
if len(requestsText) <= 0 {
requestsText = append(requestsText, "You have not requested to install this app")
}
secondaryText = append(secondaryText, requestsText...)
return
}

// formatRequestTime displays a Unix timestamp in the local timezone
func formatRequestTime(timestamp int64) string {
if timestamp <= 0 {
return "unknown"
}
return time.Unix(timestamp, 0).Format(requestsTimeFormat)
}

// formatRequestCancelledBy names the kind of actor that cancelled a request.
// Every returned request was made by the authenticated account, so a request
// cancelled by a user was withdrawn by that same account.
func formatRequestCancelledBy(actor api.AppsApprovalsRequestCancelledBy) string {
switch actor {
case api.AppsApprovalsRequestCancelledByAdmin:
return "an admin"
case api.AppsApprovalsRequestCancelledBySystem:
return "the system"
case api.AppsApprovalsRequestCancelledByUser:
return "you"
default:
return string(actor)
}
}

// formatRequestStatus styles a status by how much attention it deserves
func formatRequestStatus(status api.AppsApprovalsRequestStatus) string {
switch status {
case api.AppsApprovalsRequestStatusApproved:
return style.Green(string(status))
case api.AppsApprovalsRequestStatusCancelled:
return style.Secondary(string(status))
case api.AppsApprovalsRequestStatusDenied:
return style.Red(string(status))
case api.AppsApprovalsRequestStatusPending:
return style.Yellow(string(status))
default:
return string(status)
}
}
Loading
Loading