diff --git a/go.mod b/go.mod index efabb7a..d6b8fb6 100644 --- a/go.mod +++ b/go.mod @@ -121,4 +121,7 @@ tool ( github.com/pact-foundation/pact-go/v2 ) -// replace github.com/snyk/go-application-framework => ../../go-application-framework +// Requires go-application-framework with pkg/contributorbilling (IANDT-237). +// Pinned to pre-release commit until GAF ships; bump to tagged release before merge to main. +// For local dev against ../go-application-framework: +// replace github.com/snyk/go-application-framework => ../go-application-framework diff --git a/internal/commands/code_workflow/native_workflow.go b/internal/commands/code_workflow/native_workflow.go index ddffc10..5c89ab6 100644 --- a/internal/commands/code_workflow/native_workflow.go +++ b/internal/commands/code_workflow/native_workflow.go @@ -14,6 +14,7 @@ import ( codeclient "github.com/snyk/code-client-go" "github.com/snyk/code-client-go/bundle" codeclienthttp "github.com/snyk/code-client-go/http" + "github.com/snyk/code-client-go/internal/contributorbilling" "github.com/snyk/code-client-go/observability" "github.com/snyk/code-client-go/sarif" "github.com/snyk/code-client-go/scan" @@ -111,6 +112,30 @@ func trackUsage(network networking.NetworkAccess, config configuration.Configura resp.Body.Close() } +func maybeEmitContributorBilling( + invocationCtx workflow.InvocationContext, + config configuration.Configuration, + path string, + resultMetaData *scan.ResultMetaData, + analyzeErr error, +) { + if analyzeErr != nil || resultMetaData == nil || resultMetaData.ProjectId == "" { + return + } + + reportMode, reportErr := GetReportMode(config) + if reportErr != nil || reportMode == noReport { + return + } + + repoPath := config.GetString(configuration.INPUT_DIRECTORY) + if repoPath == "" { + repoPath = path + } + + contributorbilling.EmitProject(invocationCtx.Context(), invocationCtx, resultMetaData.ProjectId, repoPath) +} + func EntryPointNative(invocationCtx workflow.InvocationContext, opts ...OptionalAnalysisFunctions) ([]workflow.Data, error) { // get necessary objects from invocation context config := invocationCtx.GetConfiguration() @@ -134,6 +159,8 @@ func EntryPointNative(invocationCtx workflow.InvocationContext, opts ...Optional return nil, err } + maybeEmitContributorBilling(invocationCtx, config, path, resultMetaData, err) + logger.Debug().Msgf("Result metadata: %+v", resultMetaData) resultAvailable := true diff --git a/internal/contributorbilling/emit.go b/internal/contributorbilling/emit.go new file mode 100644 index 0000000..c858314 --- /dev/null +++ b/internal/contributorbilling/emit.go @@ -0,0 +1,62 @@ +package contributorbilling + +import ( + "context" + "strings" + + "github.com/snyk/go-application-framework/pkg/configuration" + "github.com/snyk/go-application-framework/pkg/contributorbilling" + "github.com/snyk/go-application-framework/pkg/workflow" +) + +func defaultRepoPath(repoPath string) string { + if strings.TrimSpace(repoPath) == "" { + return "." + } + return repoPath +} + +func authHeader(config configuration.Configuration) string { + if token := strings.TrimSpace(config.GetString(configuration.AUTHENTICATION_TOKEN)); token != "" { + return "token " + token + } + if bearer := strings.TrimSpace(config.GetString(configuration.AUTHENTICATION_BEARER_TOKEN)); bearer != "" { + return "Bearer " + bearer + } + return "" +} + +// EmitProject fires contributor billing after a successful native code report test. +// It is fire-and-forget and must not affect command exit codes. +func EmitProject( + ctx context.Context, + ictx workflow.InvocationContext, + projectID string, + repoPath string, +) { + projectID = strings.TrimSpace(projectID) + if projectID == "" || ictx == nil { + return + } + + config := ictx.GetConfiguration() + scopeID := strings.TrimSpace(config.GetString(configuration.ORGANIZATION)) + if scopeID == "" { + return + } + + contributorbilling.EmitContributorBilling(ctx, contributorbilling.EmitOptions{ + HTTPClient: ictx.GetNetworkAccess().GetHttpClient(), + IngestURL: config.GetString(configuration.API_URL), + AuthHeader: authHeader(config), + Capability: contributorbilling.CapabilityCode, + ScopeID: scopeID, + RepoPath: defaultRepoPath(repoPath), + CollectContributors: true, + Timeout: contributorbilling.DefaultTimeout, + Logger: ictx.GetEnhancedLogger(), + Items: []contributorbilling.BillingItem{ + {EntityID: projectID}, + }, + }) +} diff --git a/internal/contributorbilling/emit_test.go b/internal/contributorbilling/emit_test.go new file mode 100644 index 0000000..a1f3e48 --- /dev/null +++ b/internal/contributorbilling/emit_test.go @@ -0,0 +1,73 @@ +package contributorbilling_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/rs/zerolog" + "github.com/snyk/go-application-framework/pkg/configuration" + "github.com/snyk/go-application-framework/pkg/contributorbilling" + "github.com/snyk/go-application-framework/pkg/mocks" + "github.com/stretchr/testify/require" + + billing "github.com/snyk/code-client-go/internal/contributorbilling" +) + +func TestEmitProject(t *testing.T) { + t.Parallel() + + var ( + mu sync.Mutex + requests []map[string]any + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + + mu.Lock() + requests = append(requests, payload) + mu.Unlock() + + w.WriteHeader(http.StatusCreated) + })) + t.Cleanup(server.Close) + + ctrl := gomock.NewController(t) + logger := zerolog.Nop() + config := configuration.NewWithOpts() + config.Set(configuration.API_URL, server.URL) + config.Set(configuration.ORGANIZATION, "11111111-1111-1111-1111-111111111111") + config.Set(configuration.AUTHENTICATION_TOKEN, "test-token") + + networkAccess := mocks.NewMockNetworkAccess(ctrl) + networkAccess.EXPECT().GetHttpClient().Return(server.Client()).AnyTimes() + + invocation := mocks.NewMockInvocationContext(ctrl) + invocation.EXPECT().GetConfiguration().Return(config).AnyTimes() + invocation.EXPECT().GetEnhancedLogger().Return(&logger).AnyTimes() + invocation.EXPECT().GetNetworkAccess().Return(networkAccess).AnyTimes() + + billing.EmitProject( + context.Background(), + invocation, + "22222222-2222-2222-2222-222222222222", + "/tmp/repo", + ) + + require.True(t, contributorbilling.WaitWithTimeout(2*time.Second)) + + mu.Lock() + defer mu.Unlock() + require.Len(t, requests, 1) +}