diff --git a/cmd/glab/main.go b/cmd/glab/main.go index 15fc8b140c40d1582b0dc291928f80223a6573a5..e7b461a58b3d9f458e06ac8765244d06c544df40 100644 --- a/cmd/glab/main.go +++ b/cmd/glab/main.go @@ -20,6 +20,7 @@ import ( "gitlab.com/gitlab-org/cli/commands/alias/expand" "gitlab.com/gitlab-org/cli/commands/cmdutils" "gitlab.com/gitlab-org/cli/commands/help" + "gitlab.com/gitlab-org/cli/commands/hooks" "gitlab.com/gitlab-org/cli/commands/update" "gitlab.com/gitlab-org/cli/internal/config" "gitlab.com/gitlab-org/cli/internal/run" @@ -109,6 +110,9 @@ func main() { } cmd, _, err := rootCmd.Traverse(expandedArgs) + + checkForTelemetryHook(cfg, cmdFactory, cmd) + if err != nil || cmd == rootCmd { originalArgs := expandedArgs isShell := false @@ -248,3 +252,9 @@ func maybeOverrideDefaultHost(f *cmdutils.Factory, cfg config.Config) { glinstance.OverrideDefault(customGLHost) } } + +func checkForTelemetryHook(cfg config.Config, f *cmdutils.Factory, cmd *cobra.Command) { + if hooks.IsTelemetryEnabled(cfg) { + cobra.OnFinalize(hooks.AddTelemetryHook(f, cmd)) + } +} diff --git a/commands/hooks/hooks.go b/commands/hooks/hooks.go new file mode 100644 index 0000000000000000000000000000000000000000..317e272c3c066383d4bf2124a9da901be9e67eda --- /dev/null +++ b/commands/hooks/hooks.go @@ -0,0 +1,78 @@ +package hooks + +import ( + "strings" + + "github.com/spf13/cobra" + gitlab "gitlab.com/gitlab-org/api/client-go" + "gitlab.com/gitlab-org/cli/commands/cmdutils" + "gitlab.com/gitlab-org/cli/internal/config" +) + +func AddTelemetryHook(f *cmdutils.Factory, cmd *cobra.Command) func() { + return func() { + go sendTelemetryData(f, cmd) + } +} + +// IsTelemetryEnabled checks if usage data is disabled via config or env var +func IsTelemetryEnabled(cfg config.Config) bool { + telemetryEnabled, _ := cfg.Get("", "telemetry") + if telemetryEnabled == "false" || telemetryEnabled == "0" { + return false + } + + return true +} + +// parseCommand parses a command string and returns components +func parseCommand(parts []string) (command, subcommand, fullCommand string) { + if len(parts) < 2 { + return "", "", "" + } + + // glab is always the first value, command is the next + command = parts[1] + + subcommandParts := parts[2:] + subcommand = strings.Join(subcommandParts, " ") + + fullCommand = command + if subcommand != "" { + fullCommand += " " + subcommand + } + + return command, subcommand, fullCommand +} + +func sendTelemetryData(f *cmdutils.Factory, cmd *cobra.Command) { + var projectID int + var namespaceID int + unparsedCommand := strings.Split(cmd.CommandPath(), " ") + + command, subcommand, fullCommand := parseCommand(unparsedCommand) + + client, _ := f.HttpClient() + + repo, _ := f.BaseRepo() + + project, err := repo.Project(client) + if err == nil { + projectID = project.ID + namespaceID = project.Namespace.ID + } + + if client != nil { + _, _ = client.UsageData.TrackEvent(&gitlab.TrackEventOptions{ + Event: "gitlab_cli_command_used", + NamespaceID: gitlab.Ptr(namespaceID), + ProjectID: gitlab.Ptr(projectID), + SendToSnowplow: gitlab.Ptr(true), + AdditionalProperties: map[string]string{ + "label": command, + "property": subcommand, + "command_and_subcommand": fullCommand, + }, + }) + } +} diff --git a/commands/hooks/hooks_test.go b/commands/hooks/hooks_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0e184797779c2a5bb106c659aee6f9dde5b4d8ea --- /dev/null +++ b/commands/hooks/hooks_test.go @@ -0,0 +1,225 @@ +package hooks + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + gitlab "gitlab.com/gitlab-org/api/client-go" + gitlab_testing "gitlab.com/gitlab-org/api/client-go/testing" + "go.uber.org/mock/gomock" + + "gitlab.com/gitlab-org/cli/commands/cmdtest" + "gitlab.com/gitlab-org/cli/commands/cmdutils" + "gitlab.com/gitlab-org/cli/internal/config" + "gitlab.com/gitlab-org/cli/internal/glrepo" +) + +func Test_sendTelemetryData(t *testing.T) { + tests := []struct { + name string + args []string + cobraMocks []*cobra.Command + command string + subcommand string + fullCommand string + }{ + { + name: "command with subcommand", + cobraMocks: []*cobra.Command{ + {Use: "glab"}, + {Use: "mr"}, + {Use: "view"}, + }, + command: "mr", + subcommand: "view", + fullCommand: "mr view", + }, + { + name: "command with multiple subcommands", + cobraMocks: []*cobra.Command{ + {Use: "glab"}, + {Use: "command"}, + {Use: "subcommand1"}, + {Use: "subcommand2"}, + }, + args: []string{"glab", "command", "subcommand1", "subcommand2"}, + command: "command", + subcommand: "subcommand1 subcommand2", + fullCommand: "command subcommand1 subcommand2", + }, + { + name: "single command only", + cobraMocks: []*cobra.Command{ + {Use: "glab"}, + {Use: "version"}, + }, + command: "version", + subcommand: "", + fullCommand: "version", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tc := gitlab_testing.NewTestClient(t) + ios, _, _, _ := cmdtest.InitIOStreams(true, "") + + f := &cmdutils.Factory{ + IO: ios, + HttpClient: func() (*gitlab.Client, error) { + return tc.Client, nil + }, + BaseRepo: func() (glrepo.Interface, error) { + return glrepo.New("OWNER", "REPO"), nil + }, + } + + project := gitlab.Project{ + ID: 123, + Namespace: &gitlab.ProjectNamespace{ID: 123}, + } + + tc.MockProjects.EXPECT(). + GetProject(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&project, &gitlab.Response{}, nil) + + tc.MockUsageData.EXPECT(). + TrackEvent(&gitlab.TrackEventOptions{ + Event: "gitlab_cli_command_used", + NamespaceID: gitlab.Ptr(project.Namespace.ID), + ProjectID: gitlab.Ptr(project.ID), + SendToSnowplow: gitlab.Ptr(true), + AdditionalProperties: map[string]string{ + "label": tt.command, + "property": tt.subcommand, + "command_and_subcommand": tt.fullCommand, + }, + }) + + passedCommand := tt.cobraMocks[0] + numberOfCommands := len(tt.cobraMocks) + + for i, cmd := range tt.cobraMocks { + if i < numberOfCommands && i > 0 { + tt.cobraMocks[i-1].AddCommand(cmd) + + passedCommand = cmd + } + } + + sendTelemetryData(f, passedCommand) + }) + } +} + +func Test_parseCommand(t *testing.T) { + tests := []struct { + name string + cmdString []string + command string + subcommand string + fullCommand string + }{ + { + name: "basic command", + cmdString: []string{"glab", "mr", "list"}, + command: "mr", + subcommand: "list", + fullCommand: "mr list", + }, + { + name: "multiple subcommands", + cmdString: []string{"glab", "command", "subcommand1", "subcommand2", "subcommand3"}, + command: "command", + subcommand: "subcommand1 subcommand2 subcommand3", + fullCommand: "command subcommand1 subcommand2 subcommand3", + }, + { + name: "no subcommand", + cmdString: []string{"glab", "mr"}, + command: "mr", + subcommand: "", + fullCommand: "mr", + }, + { + name: "too short of a command", + cmdString: []string{"glab"}, + command: "", + subcommand: "", + fullCommand: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + command, subcommand, fullCommand := parseCommand(tt.cmdString) + + require := require.New(t) + require.Equal(tt.command, command) + require.Equal(tt.subcommand, subcommand) + require.Equal(tt.fullCommand, fullCommand) + }) + } +} + +func TestIsTelemetryEnabled(t *testing.T) { + tests := []struct { + name string + configYaml string + expectedResult bool + }{ + { + name: "enabled with 'true' value", + configYaml: "telemetry: true", + expectedResult: true, + }, + { + name: "enabled with '1' value", + configYaml: "telemetry: '1'", + expectedResult: true, + }, + { + name: "disabled with 'false' value", + configYaml: "telemetry: false", + expectedResult: false, + }, + { + name: "disabled with '0' value", + configYaml: "telemetry: '0'", + expectedResult: false, + }, + { + name: "enabled with empty value", + configYaml: "telemetry: ''", + expectedResult: true, + }, + { + name: "enabled with other value", + configYaml: "telemetry: something", + expectedResult: true, + }, + { + name: "no config value set", + expectedResult: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + restore := config.StubConfig(tt.configYaml, "") + defer restore() + + cfg, err := config.ParseConfig("config.yml") + if tt.configYaml == "" { + cfg = config.NewBlankConfig() + } else { + require.NoError(t, err) + } + + result := IsTelemetryEnabled(cfg) + + require.Equal(t, tt.expectedResult, result) + }) + } +}