-
Notifications
You must be signed in to change notification settings - Fork 44
feat: add 'app request' to check install approval requests status #646
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
d6c7a98
1e4be76
8826cbf
4a96146
373e8b7
a82393a
621d13e
7dce5c6
c326c6c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,242 @@ | ||||||
| // 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" | ||||||
| ) | ||||||
|
|
||||||
| // requestsTeamsLimit is the most teams the API searches in a single call | ||||||
| const requestsTeamsLimit = 50 | ||||||
|
|
||||||
| // 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 | ||||||
|
|
||||||
|
AmyScript marked this conversation as resolved.
Outdated
|
||||||
| type requestsCmdFlags struct { | ||||||
| teamIDs []string | ||||||
| } | ||||||
|
|
||||||
| var requestsFlags requestsCmdFlags | ||||||
|
|
||||||
| // NewRequestsCommand returns a new Cobra command | ||||||
| func NewRequestsCommand(clients *shared.ClientFactory) *cobra.Command { | ||||||
| cmd := &cobra.Command{ | ||||||
| Use: "requests [flags]", | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: After some thought, I'd like to suggest renaming this to The reason is that We've had requests for a cancel feature, so this opens the namespace for it. The bare The
We should alias the plural
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 On the requester side, request also shows up in our CLI and UI:
Good point about leaving room for subcommands. The requester side verbs are create, cancel, and list, and those read naturally as You're right that we should use the singular, so I've renamed it to |
||||||
| Aliases: []string{"approval-requests", "approvals"}, | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @mwbrooks here are the aliases I was talking about.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: Assuming we go with
Suggested change
|
||||||
| Short: "Check requests to install the app", | ||||||
|
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 --team-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 --team-ids T0123456789,T9876543210", Meaning: "Check requests on certain teams 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"), | ||||||
| ) | ||||||
| } | ||||||
| if len(requestsFlags.teamIDs) > requestsTeamsLimit { | ||||||
| return slackerror.New(slackerror.ErrInvalidArguments). | ||||||
| WithMessage("The %s flag accepts at most %d teams", | ||||||
| style.CommandText("--team-ids"), | ||||||
| requestsTeamsLimit, | ||||||
| ) | ||||||
| } | ||||||
|
zimeg marked this conversation as resolved.
Outdated
|
||||||
| 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 | ||||||
| return cmdutil.IsValidProjectDirectory(clients) | ||||||
|
zimeg marked this conversation as resolved.
Outdated
|
||||||
| }, | ||||||
| RunE: func(cmd *cobra.Command, args []string) error { | ||||||
| return runRequestsCommand(cmd, clients) | ||||||
| }, | ||||||
| } | ||||||
|
|
||||||
| cmd.Flags().StringSliceVar(&requestsFlags.teamIDs, "team-ids", nil, "also check these teams of an organization,\nwith a maximum of 50 teams") | ||||||
|
zimeg 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") | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: If we accept
Suggested change
|
||||||
| defer span.Finish() | ||||||
|
|
||||||
| appID, token, err := requestsAppSelection(ctx, clients) | ||||||
| if err != nil { | ||||||
| return err | ||||||
| } | ||||||
|
|
||||||
| result, err := clients.API().ListAppApprovalRequests(ctx, token, appID, requestsFlags.teamIDs) | ||||||
| if err != nil { | ||||||
| return err | ||||||
| } | ||||||
|
|
||||||
| clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ | ||||||
| Emoji: "lock", | ||||||
| Text: "App Requests", | ||||||
|
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) | ||||||
| } | ||||||
| clients.Auth().SetSelectedAuth(ctx, *auth, clients.Config, clients.Os) | ||||||
|
AmyScript marked this conversation as resolved.
Outdated
|
||||||
| 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 { | ||||||
|
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) | ||||||
| } | ||||||
| } | ||||||
Uh oh!
There was an error while loading. Please reload this page.