diff --git a/Makefile b/Makefile index 765418e0..bd4292b0 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -VERSION=v0.2.14 +VERSION=v0.2.15 OUT_DIR=dist YEAR?=$(shell date +"%Y") diff --git a/cmd/commands/product-release.go b/cmd/commands/product-release.go index b8ecb0ca..16580c39 100644 --- a/cmd/commands/product-release.go +++ b/cmd/commands/product-release.go @@ -38,7 +38,74 @@ type ( } ) +const pre_v1_3808_0_err_message = "Cannot query field \\\"promotions\\\" on type \\\"Query\\\"" +const pre_v1_3728_0_err_message = "Unknown argument \"productNames\"" +const pre_v1_3120_1_err_message = "Cannot query field \\\"applications\\\" on type \\\"ProductReleaseStep\\\"." const latest_query = ` + query Promotions($filters: ProductReleaseFiltersArgs, $pagination: SlicePaginationArgs) { + promotions(filters: $filters, pagination: $pagination) { + pageInfo { + hasNextPage + hasPrevPage + startCursor + endCursor + } + edges { + node { + __typename + ... on ProductRelease { + releaseId + releaseName + productName + promotionFlowName + error { + message + code + } + status + environmentsStatuses { + __typename + environmentName + status + } + createdAt + triggerCommit { + sha + } + initiator { + name + avatarUrl + } + version + } + ... on Promotion { + id + productName + promotionFlowName + status + environments { + __typename + name + status + } + createdAt + triggerCommitInfo { + commitSha + commitAuthor + avatarURL + } + promotionAppVersion + failure { + message + } + } + } + } + } + } +` + +const pre_v1_3808_0_query = ` query getProductReleasesList( $productName: String! $filters: ProductReleaseFiltersArgs! @@ -160,9 +227,9 @@ func newProductReleaseListCommand() *cobra.Command { func runProductReleaseList(ctx context.Context, filterArgs platmodel.ProductReleaseFiltersArgs, productName string, pageLimit int) error { // add pagination - default for now is last 20 + filterArgs.ProductNames = []string{productName} variables := map[string]any{ - "filters": filterArgs, - "productName": productName, + "filters": filterArgs, "pagination": platmodel.SlicePaginationArgs{ First: &pageLimit, }, @@ -170,9 +237,19 @@ func runProductReleaseList(ctx context.Context, filterArgs platmodel.ProductRele productReleasesPage, err := client.GraphqlAPI[productReleaseSlice](ctx, cfConfig.NewClient().InternalClient(), latest_query, variables) if err != nil { - if strings.Contains(err.Error(), "Cannot query field \\\"applications\\\" on type \\\"ProductReleaseStep\\\".") { + pre_v1_3808_0_variables := map[string]any{ + "filters": filterArgs, + "productName": productName, + "pagination": platmodel.SlicePaginationArgs{ + First: &pageLimit, + }, + } + if strings.Contains(err.Error(), pre_v1_3120_1_err_message) { log.G().Warn("codefresh version older than v1.3120.1 detected. Using pre v1.3120.1 query which excludes applications.") - productReleasesPage, err = client.GraphqlAPI[productReleaseSlice](ctx, cfConfig.NewClient().InternalClient(), pre_v1_3120_1_query, variables) + productReleasesPage, err = client.GraphqlAPI[productReleaseSlice](ctx, cfConfig.NewClient().InternalClient(), pre_v1_3120_1_query, pre_v1_3808_0_variables) + } else if strings.Contains(err.Error(), pre_v1_3808_0_err_message) || strings.Contains(err.Error(), pre_v1_3728_0_err_message) { + log.G().Warn("codefresh version older than v1.3808.0 detected. Using pre v1.3808.0 for old arch without promotions.") + productReleasesPage, err = client.GraphqlAPI[productReleaseSlice](ctx, cfConfig.NewClient().InternalClient(), pre_v1_3808_0_query, pre_v1_3808_0_variables) } if err != nil { return fmt.Errorf("failed to get product releases: %s", err.Error()) diff --git a/cmd/commands/product-release_test.go b/cmd/commands/product-release_test.go index 3f7f7724..bb901fea 100644 --- a/cmd/commands/product-release_test.go +++ b/cmd/commands/product-release_test.go @@ -133,7 +133,7 @@ func Test_ExtractNodesFromEdges(t *testing.T) { wantErr string }{ { - name: "should extract node", + name: "should extract node from ProductRelease", args: args{ edges: getProductReleaseMock().Edges, }, @@ -155,6 +155,87 @@ func Test_ExtractNodesFromEdges(t *testing.T) { } } +func Test_ExtractNodesFromEdges_WithPromotions(t *testing.T) { + type args struct { + edges []productReleaseEdge + } + expected, err := getPromotionJsonStringMock() + + tests := []struct { + name string + args args + want []map[string]any + wantErr string + }{ + { + name: "should extract node from Promotion", + args: args{ + edges: getPromotionMock().Edges, + }, + want: expected, + wantErr: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + nodes := extractNodesFromEdges(tt.args.edges) + if err != nil || tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + stringNodes := fmt.Sprintf("%v", nodes) + stringWant := fmt.Sprintf("%v", tt.want) + assert.Equal(t, stringNodes, stringWant) + }) + } +} + +func Test_ExtractNodesFromEdges_Mixed(t *testing.T) { + type args struct { + edges []productReleaseEdge + } + productReleaseMock := getProductReleaseMock() + promotionMock := getPromotionMock() + + // Create mixed edges + mixedEdges := append(productReleaseMock.Edges, promotionMock.Edges...) + + tests := []struct { + name string + args args + wantLen int + }{ + { + name: "should extract nodes from mixed ProductRelease and Promotion", + args: args{ + edges: mixedEdges, + }, + wantLen: len(productReleaseMock.Edges) + len(promotionMock.Edges), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + nodes := extractNodesFromEdges(tt.args.edges) + assert.Equal(t, tt.wantLen, len(nodes)) + + // Verify first node is ProductRelease type + if len(nodes) > 0 { + _, hasReleaseId := nodes[0]["releaseId"] + assert.True(t, hasReleaseId, "First node should have releaseId (ProductRelease)") + } + + // Verify last nodes are Promotion type + if len(nodes) > len(productReleaseMock.Edges) { + lastNode := nodes[len(nodes)-1] + _, hasId := lastNode["id"] + _, hasTypename := lastNode["__typename"] + assert.True(t, hasId, "Last node should have id (Promotion)") + assert.True(t, hasTypename, "Last node should have __typename (Promotion)") + } + }) + } +} + func getProductReleaseMock() productReleaseSlice { node1 := map[string]any{ "releaseId": "669fac7668fc487b38c2ad19", @@ -227,3 +308,269 @@ func getProductReleaseJsonStringMock() ([]map[string]any, error) { } return result, nil } + +func getPromotionMock() productReleaseSlice { + node1 := map[string]any{ + "__typename": "Promotion", + "createdAt": "2025-11-12T07:58:00.829Z", + "environments": []map[string]interface{}{ + { + "__typename": "PromotionEnvironment", + "name": "dev", + "status": "TERMINATED", + }, + { + "__typename": "PromotionEnvironment", + "name": "staging", + "status": "SKIPPED", + }, + }, + "failure": map[string]interface{}{ + "message": "terminated by concurrency", + }, + "id": "69143e08d3fc600692f605b6", + "productName": "my-product", + "promotionAppVersion": "0.1.0", + "promotionFlowName": "test-post-trigger", + "status": "TERMINATED", + "triggerCommitInfo": map[string]interface{}{ + "avatarURL": "https://avatars.githubusercontent.com/u/88274488?v=4", + "commitAuthor": "kim-codefresh ", + "commitSha": "b03059b9defaaef46f7d3a817eb9449cf8d89730", + }, + } + edge1 := productReleaseEdge{ + Node: node1, + } + + node2 := map[string]any{ + "__typename": "Promotion", + "createdAt": "2025-11-12T07:57:04.410Z", + "environments": []map[string]interface{}{ + { + "__typename": "PromotionEnvironment", + "name": "dev", + "status": "TERMINATED", + }, + { + "__typename": "PromotionEnvironment", + "name": "staging", + "status": "SKIPPED", + }, + }, + "failure": map[string]interface{}{ + "message": "terminated by concurrency", + }, + "id": "69143dd0d3fc600692f6056d", + "productName": "my-product", + "promotionAppVersion": "0.1.0", + "promotionFlowName": "test-post-trigger", + "status": "TERMINATED", + "triggerCommitInfo": map[string]interface{}{ + "avatarURL": "https://avatars.githubusercontent.com/u/88274488?v=4", + "commitAuthor": "kim-codefresh ", + "commitSha": "b03059b9defaaef46f7d3a817eb9449cf8d89730", + }, + } + edge2 := productReleaseEdge{ + Node: node2, + } + + node3 := map[string]any{ + "__typename": "Promotion", + "createdAt": "2025-11-11T11:37:10.675Z", + "environments": []map[string]interface{}{ + { + "__typename": "PromotionEnvironment", + "name": "dev", + "status": "SUCCEEDED", + }, + { + "__typename": "PromotionEnvironment", + "name": "staging", + "status": "SUCCEEDED", + }, + { + "__typename": "PromotionEnvironment", + "name": "production", + "status": "TERMINATED", + }, + }, + "failure": map[string]interface{}{ + "message": "terminated by concurrency", + }, + "id": "69131fe6f2ed885fcec97175", + "productName": "my-product", + "promotionAppVersion": "0.1.0", + "promotionFlowName": "demo", + "status": "TERMINATED", + "triggerCommitInfo": map[string]interface{}{ + "avatarURL": "https://avatars.githubusercontent.com/u/88274488?v=4", + "commitAuthor": "kim-codefresh ", + "commitSha": "b03059b9defaaef46f7d3a817eb9449cf8d89730", + }, + } + edge3 := productReleaseEdge{ + Node: node3, + } + + slice := productReleaseSlice{ + Edges: []productReleaseEdge{ + edge1, + edge2, + edge3, + }, + } + return slice +} + +func getPromotionJsonStringMock() ([]map[string]any, error) { + file, err := os.Open("./promotion_mock.json") + if err != nil { + return nil, err + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + return nil, err + } + + var result []map[string]interface{} + err = json.Unmarshal(data, &result) + if err != nil { + return nil, err + } + return result, nil +} + +func Test_ToProductReleaseStatus_ErrorCases(t *testing.T) { + tests := []struct { + name string + input []string + wantErr string + }{ + { + name: "should fail with semicolon in status", + input: []string{"running; failed"}, + wantErr: "invalid product release status: running; failed", + }, + { + name: "should fail with completely invalid status", + input: []string{"invalid-status"}, + wantErr: "invalid product release status: invalid-status", + }, + { + name: "should fail with empty status string", + input: []string{""}, + wantErr: "invalid product release status: ", + }, + { + name: "should fail with mixed valid and invalid", + input: []string{"RUNNING", "INVALID", "FAILED"}, + wantErr: "invalid product release status: INVALID", + }, + { + name: "should fail with numeric status", + input: []string{"123"}, + wantErr: "invalid product release status: 123", + }, + { + name: "should fail with special characters", + input: []string{"RUNNING@#$"}, + wantErr: "invalid product release status: RUNNING@#$", + }, + { + name: "should fail with SQL injection attempt", + input: []string{"RUNNING' OR '1'='1"}, + wantErr: "invalid product release status: RUNNING' OR '1'='1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := toProductReleaseStatus(tt.input) + assert.Error(t, err) + assert.EqualError(t, err, tt.wantErr) + }) + } +} + +func Test_ToProductReleaseStatus_SuccessCases(t *testing.T) { + tests := []struct { + name string + input []string + expected []platmodel.ProductReleaseStatus + }{ + { + name: "should handle lowercase statuses", + input: []string{"running", "failed", "succeeded"}, + expected: []platmodel.ProductReleaseStatus{ + platmodel.ProductReleaseStatusRunning, + platmodel.ProductReleaseStatusFailed, + platmodel.ProductReleaseStatusSucceeded, + }, + }, + { + name: "should handle uppercase statuses", + input: []string{"RUNNING", "FAILED", "SUCCEEDED"}, + expected: []platmodel.ProductReleaseStatus{ + platmodel.ProductReleaseStatusRunning, + platmodel.ProductReleaseStatusFailed, + platmodel.ProductReleaseStatusSucceeded, + }, + }, + { + name: "should handle mixed case statuses", + input: []string{"RuNnInG", "FaIlEd", "SuCcEeDeD"}, + expected: []platmodel.ProductReleaseStatus{ + platmodel.ProductReleaseStatusRunning, + platmodel.ProductReleaseStatusFailed, + platmodel.ProductReleaseStatusSucceeded, + }, + }, + { + name: "should trim whitespace", + input: []string{" RUNNING ", "FAILED ", " SUCCEEDED"}, + expected: []platmodel.ProductReleaseStatus{ + platmodel.ProductReleaseStatusRunning, + platmodel.ProductReleaseStatusFailed, + platmodel.ProductReleaseStatusSucceeded, + }, + }, + { + name: "should handle empty array", + input: []string{}, + expected: nil, + }, + { + name: "should handle single status", + input: []string{"RUNNING"}, + expected: []platmodel.ProductReleaseStatus{ + platmodel.ProductReleaseStatusRunning, + }, + }, + { + name: "should handle suspended status", + input: []string{"SUSPENDED"}, + expected: []platmodel.ProductReleaseStatus{ + platmodel.ProductReleaseStatusSuspended, + }, + }, + { + name: "should handle terminated status", + input: []string{"TERMINATED"}, + expected: []platmodel.ProductReleaseStatus{ + platmodel.ProductReleaseStatusTerminated, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := toProductReleaseStatus(tt.input) + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/cmd/commands/promotion_mock.json b/cmd/commands/promotion_mock.json new file mode 100644 index 00000000..37da1d7c --- /dev/null +++ b/cmd/commands/promotion_mock.json @@ -0,0 +1,95 @@ +[ + { + "__typename": "Promotion", + "createdAt": "2025-11-12T07:58:00.829Z", + "environments": [ + { + "__typename": "PromotionEnvironment", + "name": "dev", + "status": "TERMINATED" + }, + { + "__typename": "PromotionEnvironment", + "name": "staging", + "status": "SKIPPED" + } + ], + "failure": { + "message": "terminated by concurrency" + }, + "id": "69143e08d3fc600692f605b6", + "productName": "my-product", + "promotionAppVersion": "0.1.0", + "promotionFlowName": "test-post-trigger", + "status": "TERMINATED", + "triggerCommitInfo": { + "avatarURL": "https://avatars.githubusercontent.com/u/88274488?v=4", + "commitAuthor": "kim-codefresh ", + "commitSha": "b03059b9defaaef46f7d3a817eb9449cf8d89730" + } + }, + { + "__typename": "Promotion", + "createdAt": "2025-11-12T07:57:04.410Z", + "environments": [ + { + "__typename": "PromotionEnvironment", + "name": "dev", + "status": "TERMINATED" + }, + { + "__typename": "PromotionEnvironment", + "name": "staging", + "status": "SKIPPED" + } + ], + "failure": { + "message": "terminated by concurrency" + }, + "id": "69143dd0d3fc600692f6056d", + "productName": "my-product", + "promotionAppVersion": "0.1.0", + "promotionFlowName": "test-post-trigger", + "status": "TERMINATED", + "triggerCommitInfo": { + "avatarURL": "https://avatars.githubusercontent.com/u/88274488?v=4", + "commitAuthor": "kim-codefresh ", + "commitSha": "b03059b9defaaef46f7d3a817eb9449cf8d89730" + } + }, + { + "__typename": "Promotion", + "createdAt": "2025-11-11T11:37:10.675Z", + "environments": [ + { + "__typename": "PromotionEnvironment", + "name": "dev", + "status": "SUCCEEDED" + }, + { + "__typename": "PromotionEnvironment", + "name": "staging", + "status": "SUCCEEDED" + }, + { + "__typename": "PromotionEnvironment", + "name": "production", + "status": "TERMINATED" + } + ], + "failure": { + "message": "terminated by concurrency" + }, + "id": "69131fe6f2ed885fcec97175", + "productName": "my-product", + "promotionAppVersion": "0.1.0", + "promotionFlowName": "demo", + "status": "TERMINATED", + "triggerCommitInfo": { + "avatarURL": "https://avatars.githubusercontent.com/u/88274488?v=4", + "commitAuthor": "kim-codefresh ", + "commitSha": "b03059b9defaaef46f7d3a817eb9449cf8d89730" + } + } +] + diff --git a/go.mod b/go.mod index cd8aa4e4..c4263b5b 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.6 require ( github.com/Masterminds/semver/v3 v3.3.1 github.com/argoproj/argo-cd/v2 v2.14.20 - github.com/codefresh-io/go-sdk v1.4.9 + github.com/codefresh-io/go-sdk v1.4.14 github.com/fatih/color v1.18.0 github.com/gobuffalo/packr v1.30.1 github.com/golang/mock v1.6.0 diff --git a/go.sum b/go.sum index bdae1a52..d3cb92b3 100644 --- a/go.sum +++ b/go.sum @@ -99,8 +99,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/codefresh-io/go-sdk v1.4.9 h1:W6Y4CmCTx8Y1jX24tkvdzfneAjKAE9w/ApAC6LT4BEY= -github.com/codefresh-io/go-sdk v1.4.9/go.mod h1:9h1dhKQVupmt/66gWChokPfrGVFKfMMAxcfvPzh4r0U= +github.com/codefresh-io/go-sdk v1.4.14 h1:2UlaVU4RyoWnTvRiEyyJr1YyYmp5cM/NAgUZ9snTqX8= +github.com/codefresh-io/go-sdk v1.4.14/go.mod h1:9h1dhKQVupmt/66gWChokPfrGVFKfMMAxcfvPzh4r0U= github.com/containerd/containerd v1.7.27 h1:yFyEyojddO3MIGVER2xJLWoCIn+Up4GaHFquP7hsFII= github.com/containerd/containerd v1.7.27/go.mod h1:xZmPnl75Vc+BLGt4MIfu6bp+fy03gdHAn9bz+FreFR0= github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4=