Skip to content

Commit 2f62b9c

Browse files
feat: add 'app request' to check install approval requests status (#646)
* feat: check requests to install an app Add a hidden `slack app requests` command behind the app-approval-status experiment that reports the most recent install approval request for the selected app on each team in the token's scope. Co-authored-by: Cursor <cursoragent@cursor.com> * test: cover error paths and formatting fallbacks of app requests Exercise the interrupted app selection and missing app ID branches of the command, plus the unknown timestamp, status, and cancellation actor fallbacks of the output. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: check requests for an app named by ID without a project The app select prompt only offers apps saved to a project, so apps created elsewhere could not be checked. An app ID provided with the --app flag now skips both the project requirement and the project app list, gathering a token from the authenticated accounts instead. Co-authored-by: Cursor <cursoragent@cursor.com> * add ability to pass in an appId * address review comments * refactor: address review feedback on the app request command Rename the command to the singular "app request" with "requests" as the only alias, matching the CLI convention of a singular canonical name. Rename the API error codes to ErrAPIFeatureNotEnabled and ErrAPIRestrictedAction so it is clear they mirror responses of the API rather than errors raised by the CLI. Sort a copy of the requests while formatting so the slice of the caller keeps its order, and title the section "App Install Approval Requests" to spell out what is being listed. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: release the app request command without an experiment The app-approval-status experiment gated the command while the API and output were settled. Both are now agreed on, so the gate and the hidden flag come off and the command joins the app subcommands in help. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent bd2741b commit 2f62b9c

10 files changed

Lines changed: 952 additions & 2 deletions

File tree

cmd/app/app.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
5252
cmd.AddCommand(NewDeleteCommand(clients))
5353
cmd.AddCommand(NewLinkCommand(clients))
5454
cmd.AddCommand(NewListCommand(clients))
55+
cmd.AddCommand(NewRequestCommand(clients))
5556
cmd.AddCommand(NewSettingsCommand(clients))
5657
cmd.AddCommand(NewUninstallCommand(clients))
5758
cmd.AddCommand(NewUnlinkCommand(clients))

cmd/app/request.go

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package app
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"slices"
21+
"strings"
22+
"time"
23+
24+
"github.com/opentracing/opentracing-go"
25+
"github.com/slackapi/slack-cli/internal/api"
26+
"github.com/slackapi/slack-cli/internal/cmdutil"
27+
"github.com/slackapi/slack-cli/internal/prompts"
28+
"github.com/slackapi/slack-cli/internal/shared"
29+
"github.com/slackapi/slack-cli/internal/shared/types"
30+
"github.com/slackapi/slack-cli/internal/slackerror"
31+
"github.com/slackapi/slack-cli/internal/style"
32+
"github.com/spf13/cobra"
33+
)
34+
35+
// requestTimeFormat displays the moment a request changed
36+
const requestTimeFormat = "2006-01-02 15:04:05 Z07:00"
37+
38+
// Handle to a function used for testing
39+
var requestAppSelectPromptFunc = prompts.AppSelectPrompt
40+
41+
// Handle to a function used for testing
42+
var requestTeamSelectPromptFunc = prompts.PromptTeamSlackAuth
43+
44+
// Flags
45+
type requestCmdFlags struct {
46+
workspaceIDs []string
47+
}
48+
49+
var requestFlags requestCmdFlags
50+
51+
// NewRequestCommand returns a new Cobra command
52+
func NewRequestCommand(clients *shared.ClientFactory) *cobra.Command {
53+
cmd := &cobra.Command{
54+
Use: "request [flags]",
55+
Aliases: []string{"requests"},
56+
Short: "Check approval requests to install the app",
57+
Long: strings.Join([]string{
58+
"Check the status of your most recent request to have the app approved for",
59+
"install.",
60+
"",
61+
"Requests are searched on the team of the authenticated account. An account of",
62+
"a workspace that belongs to an organization also searches that organization,",
63+
"while an account of an organization searches the organization alone.",
64+
"",
65+
"Other workspaces of an organization can be searched with the --workspace-ids",
66+
"flag.",
67+
"",
68+
"Searches are made with the credentials of an authenticated account chosen",
69+
"with the --team flag or a prompt.",
70+
"",
71+
"Apps saved to a project are chosen with a prompt, but any app can be checked",
72+
"by app ID with the --app flag, which does not require a project.",
73+
}, "\n"),
74+
Example: style.ExampleCommandsf([]style.ExampleCommand{
75+
{Command: "app request", Meaning: "Check requests to install an app"},
76+
{Command: "app request --app A0123456789", Meaning: "Check requests for an app outside a project"},
77+
{Command: "app request --workspace-ids T0123456789,T9876543210", Meaning: "Check requests on certain workspaces of an organization"},
78+
}),
79+
Args: cobra.NoArgs,
80+
PreRunE: func(cmd *cobra.Command, args []string) error {
81+
clients.Config.SetFlags(cmd)
82+
// An app named by ID is checked without the apps of a project
83+
if types.IsAppID(clients.Config.AppFlag) {
84+
return nil
85+
}
86+
// Verify command is run in a project directory
87+
if err := cmdutil.IsValidProjectDirectory(clients); err != nil {
88+
invalid := slackerror.ToSlackError(err)
89+
return invalid.WithRemediation("%s\n\nApps of other projects can be checked with %s",
90+
invalid.Remediation,
91+
style.CommandText("--app A0123456789"),
92+
)
93+
}
94+
return nil
95+
},
96+
RunE: func(cmd *cobra.Command, args []string) error {
97+
return runRequestCommand(cmd, clients)
98+
},
99+
}
100+
101+
cmd.Flags().StringSliceVar(&requestFlags.workspaceIDs, "workspace-ids", nil, "also check these workspaces of an organization,\nwith a maximum of 50 workspaces")
102+
103+
return cmd
104+
}
105+
106+
// runRequestCommand will execute the request command
107+
func runRequestCommand(cmd *cobra.Command, clients *shared.ClientFactory) error {
108+
ctx := cmd.Context()
109+
span, ctx := opentracing.StartSpanFromContext(ctx, "cmd.app.request")
110+
defer span.Finish()
111+
112+
appID, auth, err := requestAppSelection(ctx, clients)
113+
if err != nil {
114+
return err
115+
}
116+
117+
result, err := clients.API().ListAppApprovalRequests(ctx, auth.Token, appID, requestFlags.workspaceIDs)
118+
if err != nil {
119+
return err
120+
}
121+
122+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
123+
Emoji: "lock",
124+
Text: "App Install Approval Requests",
125+
Secondary: FormatRequestSuccess(appID, requestTeamNames(auth), result.Requests),
126+
}))
127+
return nil
128+
}
129+
130+
// requestTeamNames collects the names of searched teams that are known.
131+
//
132+
// Requests are returned with team IDs alone, so only the team of the
133+
// authenticated account is named. Other teams of an organization are not
134+
// looked up to avoid another API call.
135+
func requestTeamNames(auth types.SlackAuth) map[string]string {
136+
if auth.TeamID == "" || auth.TeamDomain == "" {
137+
return nil
138+
}
139+
return map[string]string{auth.TeamID: auth.TeamDomain}
140+
}
141+
142+
// requestAppSelection decides the app to check and the account to search with.
143+
//
144+
// An app named by ID with the app flag is checked without a project so that
145+
// apps missing from a project can be checked too. The team of that app is
146+
// gathered from the authenticated accounts instead of the project apps.
147+
func requestAppSelection(ctx context.Context, clients *shared.ClientFactory) (appID string, auth types.SlackAuth, err error) {
148+
if types.IsAppID(clients.Config.AppFlag) {
149+
selected, err := requestTeamSelectPromptFunc(ctx, clients, "Select an account to search with", nil)
150+
if err != nil {
151+
return "", types.SlackAuth{}, err
152+
}
153+
if selected == nil || selected.Token == "" {
154+
return "", types.SlackAuth{}, slackerror.New(slackerror.ErrCredentialsNotFound)
155+
}
156+
return clients.Config.AppFlag, *selected, nil
157+
}
158+
selection, err := requestAppSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps)
159+
if err != nil {
160+
return "", types.SlackAuth{}, err
161+
}
162+
if selection.App.AppID == "" {
163+
return "", types.SlackAuth{}, slackerror.New(slackerror.ErrAppNotFound)
164+
}
165+
return selection.App.AppID, selection.Auth, nil
166+
}
167+
168+
// FormatRequestSuccess formats the install request of each team for an app.
169+
// Teams found in teamNames are titled by name while others are titled by ID.
170+
func FormatRequestSuccess(appID string, teamNames map[string]string, requests []api.AppsApprovalsRequest) (secondaryText []string) {
171+
sorted := slices.SortedFunc(slices.Values(requests), func(a api.AppsApprovalsRequest, b api.AppsApprovalsRequest) int {
172+
return strings.Compare(a.TeamID, b.TeamID)
173+
})
174+
field := func(label string, value string) string {
175+
return fmt.Sprintf(style.Indent(style.Secondary("%-13s %s")), label+":", value)
176+
}
177+
if appID != "" {
178+
secondaryText = append(secondaryText, fmt.Sprintf(style.Bold("%-13s %s"), "App ID:", appID))
179+
}
180+
// Requests are gathered apart from the app to know when none were made
181+
requestsText := []string{}
182+
for _, request := range sorted {
183+
requestsText = append(requestsText, fmt.Sprintf(style.Bold("%s:"), formatRequestTeam(teamNames, request.TeamID)))
184+
requestsText = append(requestsText, field("Request ID", request.ID))
185+
requestsText = append(requestsText, field("Status", formatRequestStatus(request.Status)))
186+
requestsText = append(requestsText, field("Requested", formatRequestTime(request.DateCreated)))
187+
if request.DateResolved > 0 {
188+
requestsText = append(requestsText, field("Resolved", formatRequestTime(request.DateResolved)))
189+
}
190+
if request.CancelledBy != "" {
191+
requestsText = append(requestsText, field("Cancelled by", formatRequestCancelledBy(request.CancelledBy)))
192+
}
193+
if request.CanSelfApprove {
194+
requestsText = append(requestsText, style.Indent(style.Secondary("You can install this app without approval. Please cancel the request.")))
195+
}
196+
}
197+
if len(requestsText) <= 0 {
198+
requestsText = append(requestsText, "You have not requested to install this app")
199+
}
200+
secondaryText = append(secondaryText, requestsText...)
201+
return
202+
}
203+
204+
// formatRequestTeam titles a team by name and ID when the name is known
205+
func formatRequestTeam(teamNames map[string]string, teamID string) string {
206+
if name, ok := teamNames[teamID]; ok {
207+
return fmt.Sprintf("%s (%s)", name, teamID)
208+
}
209+
return teamID
210+
}
211+
212+
// formatRequestTime displays a Unix timestamp in the local timezone
213+
func formatRequestTime(timestamp int64) string {
214+
if timestamp <= 0 {
215+
return "unknown"
216+
}
217+
return time.Unix(timestamp, 0).Format(requestTimeFormat)
218+
}
219+
220+
// formatRequestCancelledBy names the kind of actor that cancelled a request.
221+
// Every returned request was made by the authenticated account, so a request
222+
// cancelled by a user was withdrawn by that same account.
223+
func formatRequestCancelledBy(actor api.AppsApprovalsRequestCancelledBy) string {
224+
switch actor {
225+
case api.AppsApprovalsRequestCancelledByAdmin:
226+
return "an admin"
227+
case api.AppsApprovalsRequestCancelledBySystem:
228+
return "the system"
229+
case api.AppsApprovalsRequestCancelledByUser:
230+
return "you"
231+
default:
232+
return string(actor)
233+
}
234+
}
235+
236+
// formatRequestStatus styles a status by how much attention it deserves
237+
func formatRequestStatus(status api.AppsApprovalsRequestStatus) string {
238+
switch status {
239+
case api.AppsApprovalsRequestStatusApproved:
240+
return style.Green(string(status))
241+
case api.AppsApprovalsRequestStatusCancelled:
242+
return style.Secondary(string(status))
243+
case api.AppsApprovalsRequestStatusDenied:
244+
return style.Red(string(status))
245+
case api.AppsApprovalsRequestStatusPending:
246+
return style.Yellow(string(status))
247+
default:
248+
return string(status)
249+
}
250+
}

0 commit comments

Comments
 (0)