-
Notifications
You must be signed in to change notification settings - Fork 42
feat: check requests to install an app #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
Open
AmyScript
wants to merge
8
commits into
slackapi:main
Choose a base branch
from
AmyScript:new-approval-request-status-command
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
d6c7a98
feat: check requests to install an app
AmyScript 1e4be76
test: cover error paths and formatting fallbacks of app requests
AmyScript 8826cbf
feat: check requests for an app named by ID without a project
AmyScript 4a96146
add ability to pass in an appId
AmyScript 373e8b7
Merge branch 'main' into new-approval-request-status-command
AmyScript a82393a
address review comments
AmyScript 621d13e
Merge branch 'main' into new-approval-request-status-command
AmyScript 7dce5c6
refactor: address review feedback on the app request command
AmyScript File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,259 @@ | ||
| // 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" | ||
| "slices" | ||
| "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" | ||
| ) | ||
|
|
||
| // requestTimeFormat displays the moment a request changed | ||
| const requestTimeFormat = "2006-01-02 15:04:05 Z07:00" | ||
|
|
||
| // Handle to a function used for testing | ||
| var requestAppSelectPromptFunc = prompts.AppSelectPrompt | ||
|
|
||
| // Handle to a function used for testing | ||
| var requestTeamSelectPromptFunc = prompts.PromptTeamSlackAuth | ||
|
|
||
| // Flags | ||
| type requestCmdFlags struct { | ||
| workspaceIDs []string | ||
| } | ||
|
|
||
| var requestFlags requestCmdFlags | ||
|
|
||
| // NewRequestCommand returns a new Cobra command | ||
| func NewRequestCommand(clients *shared.ClientFactory) *cobra.Command { | ||
| cmd := &cobra.Command{ | ||
| Use: "request [flags]", | ||
| Aliases: []string{"requests"}, | ||
| Short: "Check approval requests to install the app", | ||
| 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 request", Meaning: "Check requests to install an app"}, | ||
| {Command: "app request --app A0123456789", Meaning: "Check requests for an app outside a project"}, | ||
| {Command: "app request --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 runRequestCommand(cmd, clients) | ||
| }, | ||
| } | ||
|
|
||
| cmd.Flags().StringSliceVar(&requestFlags.workspaceIDs, "workspace-ids", nil, "also check these workspaces of an organization,\nwith a maximum of 50 workspaces") | ||
|
|
||
| return cmd | ||
| } | ||
|
|
||
| // runRequestCommand will execute the request command | ||
| func runRequestCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { | ||
| ctx := cmd.Context() | ||
| span, ctx := opentracing.StartSpanFromContext(ctx, "cmd.app.request") | ||
| defer span.Finish() | ||
|
|
||
| appID, auth, err := requestAppSelection(ctx, clients) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| result, err := clients.API().ListAppApprovalRequests(ctx, auth.Token, appID, requestFlags.workspaceIDs) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ | ||
| Emoji: "lock", | ||
| Text: "App Install Approval Requests", | ||
| Secondary: FormatRequestSuccess(appID, requestTeamNames(auth), result.Requests), | ||
| })) | ||
| return nil | ||
| } | ||
|
|
||
| // requestTeamNames collects the names of searched teams that are known. | ||
| // | ||
| // Requests are returned with team IDs alone, so only the team of the | ||
| // authenticated account is named. Other teams of an organization are not | ||
| // looked up to avoid another API call. | ||
| func requestTeamNames(auth types.SlackAuth) map[string]string { | ||
| if auth.TeamID == "" || auth.TeamDomain == "" { | ||
| return nil | ||
| } | ||
| return map[string]string{auth.TeamID: auth.TeamDomain} | ||
| } | ||
|
|
||
| // requestAppSelection decides the app to check and the account to search with. | ||
| // | ||
| // 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 requestAppSelection(ctx context.Context, clients *shared.ClientFactory) (appID string, auth types.SlackAuth, err error) { | ||
| if types.IsAppID(clients.Config.AppFlag) { | ||
| selected, err := requestTeamSelectPromptFunc(ctx, clients, "Select an account to search with", nil) | ||
| if err != nil { | ||
| return "", types.SlackAuth{}, err | ||
| } | ||
| if selected == nil || selected.Token == "" { | ||
| return "", types.SlackAuth{}, slackerror.New(slackerror.ErrCredentialsNotFound) | ||
| } | ||
| return clients.Config.AppFlag, *selected, nil | ||
| } | ||
| selection, err := requestAppSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps) | ||
| if err != nil { | ||
| return "", types.SlackAuth{}, err | ||
| } | ||
| if selection.App.AppID == "" { | ||
| return "", types.SlackAuth{}, slackerror.New(slackerror.ErrAppNotFound) | ||
| } | ||
| return selection.App.AppID, selection.Auth, nil | ||
| } | ||
|
|
||
| // FormatRequestSuccess formats the install request of each team for an app. | ||
| // Teams found in teamNames are titled by name while others are titled by ID. | ||
| func FormatRequestSuccess(appID string, teamNames map[string]string, requests []api.AppsApprovalsRequest) (secondaryText []string) { | ||
| sorted := slices.SortedFunc(slices.Values(requests), func(a api.AppsApprovalsRequest, b api.AppsApprovalsRequest) int { | ||
| return strings.Compare(a.TeamID, b.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 sorted { | ||
| requestsText = append(requestsText, fmt.Sprintf(style.Bold("%s:"), formatRequestTeam(teamNames, 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 | ||
| } | ||
|
|
||
| // formatRequestTeam titles a team by name and ID when the name is known | ||
| func formatRequestTeam(teamNames map[string]string, teamID string) string { | ||
| if name, ok := teamNames[teamID]; ok { | ||
| return fmt.Sprintf("%s (%s)", name, teamID) | ||
| } | ||
| return teamID | ||
| } | ||
|
|
||
| // 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(requestTimeFormat) | ||
| } | ||
|
|
||
| // 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" | ||
|
AmyScript marked this conversation as resolved.
|
||
| 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) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
praise: Thanks for the slice fix!