diff --git a/cmd/list_operations.go b/cmd/list_operations.go new file mode 100644 index 0000000..750a461 --- /dev/null +++ b/cmd/list_operations.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "errors" + "os" + "sort" + "time" + + "github.com/amp-labs/cli/clerk" + "github.com/amp-labs/cli/flags" + "github.com/amp-labs/cli/logger" + "github.com/amp-labs/cli/request" + "github.com/amp-labs/cli/utils" + "github.com/spf13/cobra" +) + +type operationSummary struct { + Id string `json:"id"` + Action string `json:"action"` + Status string `json:"status"` + ReadType string `json:"readType,omitempty"` + StartedAt string `json:"startedAt"` + CompletedAt *string `json:"completedAt"` +} + +func summarizeOperations(operations []*request.Operation) []operationSummary { + summaries := make([]operationSummary, 0, len(operations)) + + for _, operation := range operations { + var completedAt *string + + if operation.UpdateTime != nil { + formatted := operation.UpdateTime.Format(time.RFC3339) + completedAt = &formatted + } + + summaries = append(summaries, operationSummary{ + Id: operation.Id, + Action: operation.ActionType, + Status: operation.Status, + ReadType: operation.ReadType, + StartedAt: operation.CreateTime.Format(time.RFC3339), + CompletedAt: completedAt, + }) + } + + return summaries +} + +var listOperationsCmd = &cobra.Command{ //nolint:gochecknoglobals + Use: "list:operations ", + Short: "List operations for an installation", + Long: "List recent operations for an installation.", + Args: cobra.ExactArgs(2), //nolint:mnd + Run: func(cmd *cobra.Command, args []string) { + projectId := flags.GetProjectOrFail() + apiKey := flags.GetAPIKey() + client := request.NewAPIClient(projectId, &apiKey) + + operations, err := client.ListOperations(cmd.Context(), args[0], args[1]) + if err != nil { + if errors.Is(err, clerk.ErrNoSessions) { + logger.FatalErr("Authenticated session has expired, please log in using amp login", err) + } else { + logger.FatalErr("Unable to list operations", err) + } + } + + sort.Slice(operations, func(i, j int) bool { + return operations[i].CreateTime.After(operations[j].CreateTime) + }) + + format, err := cmd.Flags().GetString("format") + if err != nil { + logger.FatalErr("Unable to read output format", err) + } + + err = utils.WriteStruct(os.Stdout, utils.Format(format), summarizeOperations(operations)) + if err != nil { + logger.FatalErr("Unable to write operations", err) + } + }, +} + +func init() { + err := flags.InitAndBindFormatFlag(listOperationsCmd) + if err != nil { + logger.FatalErr("unable to initialize flags", err) + } + + rootCmd.AddCommand(listOperationsCmd) +} diff --git a/cmd/list_operations_test.go b/cmd/list_operations_test.go new file mode 100644 index 0000000..1662ff7 --- /dev/null +++ b/cmd/list_operations_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "reflect" + "testing" + "time" + + "github.com/amp-labs/cli/request" +) + +func TestSummarizeOperationsOmitsProviderResource(t *testing.T) { + t.Parallel() + + startedAt := time.Date(2026, time.August, 19, 12, 0, 0, 0, time.UTC) + completedAt := startedAt.Add(time.Minute) + operations := []*request.Operation{ + { + Id: "operation-id", + ActionType: "proxy", + Status: "success", + Resource: "/1.0/projects/provider-record-id", + CreateTime: startedAt, + UpdateTime: &completedAt, + }, + } + + got := summarizeOperations(operations) + wantCompletedAt := "2026-08-19T12:01:00Z" + want := []operationSummary{ + { + Id: "operation-id", + Action: "proxy", + Status: "success", + StartedAt: "2026-08-19T12:00:00Z", + CompletedAt: &wantCompletedAt, + }, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("summarizeOperations() = %#v, want %#v", got, want) + } + + if reflect.ValueOf(got[0]).FieldByName("Resource").IsValid() { + t.Fatal("operation summary includes provider resource") + } +} diff --git a/request/api.go b/request/api.go index e7a9ecd..496c397 100644 --- a/request/api.go +++ b/request/api.go @@ -183,6 +183,31 @@ func (c *APIClient) ListInstallations(ctx context.Context, integrationId string) return installations, nil } +func (c *APIClient) ListOperations( + ctx context.Context, integrationId string, installationId string, +) ([]*Operation, error) { + listURL := fmt.Sprintf( + "%s/projects/%s/integrations/%s/installations/%s/operations", + c.Root, c.ProjectId, integrationId, installationId, + ) + + auth, err := c.getAuthHeader(ctx) + if err != nil { + return nil, err + } + + response := struct { + Results []*Operation `json:"results"` + }{} + + _, err = c.Client.Get(ctx, listURL, &response, auth) //nolint:bodyclose + if err != nil { + return nil, err + } + + return response.Results, nil +} + func (c *APIClient) ListConnections(ctx context.Context) ([]*Connection, error) { listURL := fmt.Sprintf("%s/projects/%s/connections", c.Root, c.ProjectId) diff --git a/request/list_operations_test.go b/request/list_operations_test.go new file mode 100644 index 0000000..a794641 --- /dev/null +++ b/request/list_operations_test.go @@ -0,0 +1,74 @@ +package request + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestListOperations(t *testing.T) { + t.Parallel() + + startedAt := time.Date(2026, time.August, 19, 0, 54, 26, 0, time.UTC) + completedAt := startedAt.Add(time.Second) + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet || + req.URL.Path != "/v1/projects/test-project/integrations/integration-id/installations/installation-id/operations" { + t.Errorf("request = %s %s, want GET operations path", req.Method, req.URL.Path) + } + + if req.Header.Get("X-Api-Key") != "test-api-key" { + t.Errorf("X-Api-Key = %q, want test-api-key", req.Header.Get("X-Api-Key")) + } + + writer.Header().Set("Content-Type", "application/json") + + err := json.NewEncoder(writer).Encode(map[string]any{ + "results": []Operation{ + { + Id: "operation-id", + InstallationId: "installation-id", + ActionType: "read", + Status: "success", + Resource: "projects", + ReadType: "scheduled", + CreateTime: startedAt, + UpdateTime: &completedAt, + }, + }, + "pagination": map[string]any{ + "done": true, + "nextPageToken": "", + }, + }) + if err != nil { + t.Errorf("encode response: %v", err) + } + })) + defer server.Close() + + apiKey := "test-api-key" + client := &APIClient{ + Root: server.URL + "/v1", + ProjectId: "test-project", + APIKey: &apiKey, + Client: &Client{Client: server.Client()}, + } + + operations, err := client.ListOperations(t.Context(), "integration-id", "installation-id") + if err != nil { + t.Fatalf("list operations: %v", err) + } + + if len(operations) != 1 { + t.Fatalf("operations count = %d, want 1", len(operations)) + } + + operation := operations[0] + if operation.Id != "operation-id" || operation.Status != "success" || operation.Resource != "projects" { + t.Fatalf("operation = %#v", operation) + } +} diff --git a/request/types.go b/request/types.go index 540388c..8f1274c 100644 --- a/request/types.go +++ b/request/types.go @@ -20,6 +20,17 @@ type Installation struct { HealthStatus string `json:"healthStatus"` } +type Operation struct { + Id string `json:"id"` + InstallationId string `json:"installationId"` + ActionType string `json:"actionType"` + Status string `json:"status"` + Resource string `json:"resource"` + ReadType string `json:"readType"` + CreateTime time.Time `json:"createTime"` + UpdateTime *time.Time `json:"updateTime"` +} + type Group struct { GroupRef string `json:"groupRef"` GroupName string `json:"groupName"`