From 18dc5e77f0d74c83978705b5640c6e4912b18bad Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 14 Apr 2026 17:52:05 +0200 Subject: [PATCH 01/29] Add sampled command telemetry --- .gitignore | 4 + Makefile | 4 + acceptance/acceptance_test.go | 11 + .../telemetry/command-invocation.txtar | 9 + .../no-telemetry-for-completion.txtar | 7 + .../no-telemetry-for-extension.txtar | 27 + .../no-telemetry-for-send-telemetry.txtar | 14 + ...metry-failure-does-not-break-command.txtar | 8 + cmd/gen-docs/main.go | 3 +- go.mod | 3 +- go.sum | 2 + .../barista/observability/telemetry.pb.go | 289 +++++ .../barista/observability/telemetry.twirp.go | 1117 +++++++++++++++++ internal/config/config.go | 15 + internal/config/config_test.go | 31 + internal/config/stub.go | 3 + internal/gh/gh.go | 2 + internal/gh/ghtelemetry/telemetry.go | 27 + internal/gh/mock/config.go | 40 +- internal/ghcmd/cmd.go | 56 +- internal/telemetry/detach_unix.go | 12 + internal/telemetry/detach_windows.go | 16 + internal/telemetry/fake.go | 13 + internal/telemetry/telemetry.go | 384 ++++++ internal/telemetry/telemetry_test.go | 624 +++++++++ pkg/cmd/auth/auth.go | 2 + pkg/cmd/completion/completion.go | 1 + pkg/cmd/config/list/list_test.go | 1 + pkg/cmd/root/extension.go | 13 +- pkg/cmd/root/extension_registration_test.go | 3 +- pkg/cmd/root/help_test.go | 3 +- pkg/cmd/root/help_topic.go | 6 + pkg/cmd/root/root.go | 7 +- pkg/cmd/send-telemetry/send_telemetry.go | 135 ++ pkg/cmd/send-telemetry/send_telemetry_test.go | 226 ++++ pkg/cmd/version/version.go | 3 +- pkg/cmdutil/telemetry.go | 66 + pkg/cmdutil/telemetry_test.go | 168 +++ 38 files changed, 3337 insertions(+), 18 deletions(-) create mode 100644 acceptance/testdata/telemetry/command-invocation.txtar create mode 100644 acceptance/testdata/telemetry/no-telemetry-for-completion.txtar create mode 100644 acceptance/testdata/telemetry/no-telemetry-for-extension.txtar create mode 100644 acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar create mode 100644 acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar create mode 100644 internal/barista/observability/telemetry.pb.go create mode 100644 internal/barista/observability/telemetry.twirp.go create mode 100644 internal/gh/ghtelemetry/telemetry.go create mode 100644 internal/telemetry/detach_unix.go create mode 100644 internal/telemetry/detach_windows.go create mode 100644 internal/telemetry/fake.go create mode 100644 internal/telemetry/telemetry.go create mode 100644 internal/telemetry/telemetry_test.go create mode 100644 pkg/cmd/send-telemetry/send_telemetry.go create mode 100644 pkg/cmd/send-telemetry/send_telemetry_test.go create mode 100644 pkg/cmdutil/telemetry.go create mode 100644 pkg/cmdutil/telemetry_test.go diff --git a/.gitignore b/.gitignore index ffcbbb6c505..461b6a5a0e2 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ vendor/ gh + +# Test coverage artifacts +coverage.out +lcov.info diff --git a/Makefile b/Makefile index 4efdbfbed1b..fb8bf40911b 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,10 @@ completions: bin/gh$(EXE) bin/gh$(EXE) completion -s fish > ./share/fish/vendor_completions.d/gh.fish bin/gh$(EXE) completion -s zsh > ./share/zsh/site-functions/_gh +.PHONY: lint +lint: + golangci-lint run ./... + # just convenience tasks around `go test` .PHONY: test test: diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 7c3c6f6ce2e..f978cc732be 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -182,6 +182,15 @@ func TestWorkflows(t *testing.T) { testscript.Run(t, testScriptParamsFor(tsEnv, "workflow")) } +func TestTelemetry(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "telemetry")) +} + func testScriptParamsFor(tsEnv testScriptEnv, command string) testscript.Params { var files []string if tsEnv.script != "" { @@ -226,6 +235,8 @@ func sharedSetup(tsEnv testScriptEnv) func(ts *testscript.Env) error { ts.Setenv("RANDOM_STRING", randomString(10)) + ts.Setenv("GH_TELEMETRY", "false") + // The sandbox overrides HOME, so git cannot find the user's global // config. Write a minimal identity so commits inside the sandbox // don't fail with "Author identity unknown". diff --git a/acceptance/testdata/telemetry/command-invocation.txtar b/acceptance/testdata/telemetry/command-invocation.txtar new file mode 100644 index 00000000000..86d668da5bf --- /dev/null +++ b/acceptance/testdata/telemetry/command-invocation.txtar @@ -0,0 +1,9 @@ +# Telemetry log mode outputs command invocation event to stderr +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +exec gh version +stderr 'Telemetry payload:' +stderr '"type": "command_invocation"' +stderr '"command": "gh version"' diff --git a/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar new file mode 100644 index 00000000000..ffde6e6059f --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar @@ -0,0 +1,7 @@ +# The completion command should not generate a telemetry event +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +exec gh completion -s bash +! stderr 'Telemetry payload:' diff --git a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar new file mode 100644 index 00000000000..b87b3e252ed --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar @@ -0,0 +1,27 @@ +# Extensions should not generate telemetry events +[!exec:bash] skip + +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +# Create a local shell extension repository +exec git init gh-hello +cp gh-hello.sh gh-hello/gh-hello +chmod 755 gh-hello/gh-hello +exec git -C gh-hello add gh-hello +exec git -C gh-hello commit -m 'init' + +# Install it locally +cd gh-hello +exec gh ext install . +cd $WORK + +# Run the extension and verify no telemetry is logged +exec gh hello +stdout 'hello from extension' +! stderr 'Telemetry payload:' + +-- gh-hello.sh -- +#!/usr/bin/env bash +echo "hello from extension" diff --git a/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar new file mode 100644 index 00000000000..7f9d0457aa9 --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar @@ -0,0 +1,14 @@ +# The send-telemetry command should not itself generate a telemetry event +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1 + +# Provide a minimal valid payload on stdin so the command can run. +# It will fail to connect but that's fine — we only care about telemetry logging. +stdin payload.json +! exec gh send-telemetry +! stderr 'Telemetry payload:' + +-- payload.json -- +{"events":[{"type":"test","dimensions":{},"measures":{}}]} diff --git a/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar b/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar new file mode 100644 index 00000000000..ca1fc4b4ad2 --- /dev/null +++ b/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar @@ -0,0 +1,8 @@ +# Command completes successfully even when telemetry endpoint is unreachable +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=enabled +env GH_TELEMETRY_SAMPLE_RATE=100 +env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1 + +exec gh version +stdout 'gh version' diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 60fd8af58d6..cb76f422087 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/internal/docs" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" @@ -53,7 +54,7 @@ func run(args []string) error { return config.NewFromString(""), nil }, ExtensionManager: &em{}, - }, "", "") + }, &telemetry.NoOpService{}, "", "") rootCmd.InitDefaultHelpCmd() if err := os.MkdirAll(*dir, 0755); err != nil { diff --git a/go.mod b/go.mod index 0fc0b1a5e68..78f0185df8a 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.4 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-version v1.9.0 github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec @@ -52,6 +53,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/theupdateframework/go-tuf/v2 v2.4.1 + github.com/twitchtv/twirp v8.1.3+incompatible github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/yuin/goldmark v1.8.2 github.com/zalando/go-keyring v0.2.8 @@ -129,7 +131,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/certificate-transparency-go v1.3.2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect diff --git a/go.sum b/go.sum index b6e6f64ed51..681ee5b463c 100644 --- a/go.sum +++ b/go.sum @@ -526,6 +526,8 @@ github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c h1:5a2XDQ github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c/go.mod h1:g85IafeFJZLxlzZCDRu4JLpfS7HKzR+Hw9qRh3bVzDI= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU= +github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= diff --git a/internal/barista/observability/telemetry.pb.go b/internal/barista/observability/telemetry.pb.go new file mode 100644 index 00000000000..db5a7d8f31c --- /dev/null +++ b/internal/barista/observability/telemetry.pb.go @@ -0,0 +1,289 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.4 +// protoc v5.29.3 +// source: observability/v1/telemetry.proto + +package observability + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// TelemetryEvent represents a single telemetry event from a client application. +type TelemetryEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required. The client application that generated the event (e.g. "github-cli", "vscode"). + App string `protobuf:"bytes,1,opt,name=app,proto3" json:"app,omitempty"` + // Required. The type of event (e.g. "usage", "lifecycle", "error"). + EventType string `protobuf:"bytes,2,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"` + // Key-value string dimensions describing the event (e.g. command, os, architecture). + Dimensions map[string]string `protobuf:"bytes,3,rep,name=dimensions,proto3" json:"dimensions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Key-value numeric measures associated with the event (e.g. duration_ms, api_calls). + Measures map[string]int64 `protobuf:"bytes,4,rep,name=measures,proto3" json:"measures,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TelemetryEvent) Reset() { + *x = TelemetryEvent{} + mi := &file_observability_v1_telemetry_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TelemetryEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TelemetryEvent) ProtoMessage() {} + +func (x *TelemetryEvent) ProtoReflect() protoreflect.Message { + mi := &file_observability_v1_telemetry_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TelemetryEvent.ProtoReflect.Descriptor instead. +func (*TelemetryEvent) Descriptor() ([]byte, []int) { + return file_observability_v1_telemetry_proto_rawDescGZIP(), []int{0} +} + +func (x *TelemetryEvent) GetApp() string { + if x != nil { + return x.App + } + return "" +} + +func (x *TelemetryEvent) GetEventType() string { + if x != nil { + return x.EventType + } + return "" +} + +func (x *TelemetryEvent) GetDimensions() map[string]string { + if x != nil { + return x.Dimensions + } + return nil +} + +func (x *TelemetryEvent) GetMeasures() map[string]int64 { + if x != nil { + return x.Measures + } + return nil +} + +// RecordEventsRequest contains a batch of telemetry events. +type RecordEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required. One or more telemetry events to record. + Events []*TelemetryEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordEventsRequest) Reset() { + *x = RecordEventsRequest{} + mi := &file_observability_v1_telemetry_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordEventsRequest) ProtoMessage() {} + +func (x *RecordEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_observability_v1_telemetry_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecordEventsRequest.ProtoReflect.Descriptor instead. +func (*RecordEventsRequest) Descriptor() ([]byte, []int) { + return file_observability_v1_telemetry_proto_rawDescGZIP(), []int{1} +} + +func (x *RecordEventsRequest) GetEvents() []*TelemetryEvent { + if x != nil { + return x.Events + } + return nil +} + +// RecordEventsResponse is intentionally empty. +type RecordEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordEventsResponse) Reset() { + *x = RecordEventsResponse{} + mi := &file_observability_v1_telemetry_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordEventsResponse) ProtoMessage() {} + +func (x *RecordEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_observability_v1_telemetry_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecordEventsResponse.ProtoReflect.Descriptor instead. +func (*RecordEventsResponse) Descriptor() ([]byte, []int) { + return file_observability_v1_telemetry_proto_rawDescGZIP(), []int{2} +} + +var File_observability_v1_telemetry_proto protoreflect.FileDescriptor + +var file_observability_v1_telemetry_proto_rawDesc = string([]byte{ + 0x0a, 0x20, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2f, + 0x76, 0x31, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x12, 0x1d, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, + 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, + 0x31, 0x22, 0xf5, 0x02, 0x0a, 0x0e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x45, + 0x76, 0x65, 0x6e, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x70, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x61, 0x70, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, + 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x65, 0x76, 0x65, 0x6e, + 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x5d, 0x0a, 0x0a, 0x64, 0x69, 0x6d, 0x65, 0x6e, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x63, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, + 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, + 0x74, 0x72, 0x79, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x69, 0x6d, 0x65, 0x6e, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x64, 0x69, 0x6d, 0x65, 0x6e, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x57, 0x0a, 0x08, 0x6d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, + 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3b, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, + 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, + 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, + 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, 0x1a, 0x3d, 0x0a, + 0x0f, 0x44, 0x69, 0x6d, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x3b, 0x0a, 0x0d, + 0x4d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, + 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, + 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x5c, 0x0a, 0x13, 0x52, 0x65, 0x63, + 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x45, 0x0a, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x2d, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, + 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, + 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, + 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x16, 0x0a, 0x14, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, + 0x87, 0x01, 0x0a, 0x0c, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x41, 0x50, 0x49, + 0x12, 0x77, 0x0a, 0x0c, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, + 0x12, 0x32, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, + 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, + 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, + 0x73, 0x66, 0x65, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, + 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2f, 0x63, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2f, 0x70, 0x6b, 0x67, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x74, 0x77, 0x69, 0x72, 0x70, 0x2f, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, + 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2f, 0x76, 0x31, 0x3b, 0x6f, 0x62, 0x73, 0x65, 0x72, + 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +}) + +var ( + file_observability_v1_telemetry_proto_rawDescOnce sync.Once + file_observability_v1_telemetry_proto_rawDescData []byte +) + +func file_observability_v1_telemetry_proto_rawDescGZIP() []byte { + file_observability_v1_telemetry_proto_rawDescOnce.Do(func() { + file_observability_v1_telemetry_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_observability_v1_telemetry_proto_rawDesc), len(file_observability_v1_telemetry_proto_rawDesc))) + }) + return file_observability_v1_telemetry_proto_rawDescData +} + +var file_observability_v1_telemetry_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_observability_v1_telemetry_proto_goTypes = []any{ + (*TelemetryEvent)(nil), // 0: clientappsfe.observability.v1.TelemetryEvent + (*RecordEventsRequest)(nil), // 1: clientappsfe.observability.v1.RecordEventsRequest + (*RecordEventsResponse)(nil), // 2: clientappsfe.observability.v1.RecordEventsResponse + nil, // 3: clientappsfe.observability.v1.TelemetryEvent.DimensionsEntry + nil, // 4: clientappsfe.observability.v1.TelemetryEvent.MeasuresEntry +} +var file_observability_v1_telemetry_proto_depIdxs = []int32{ + 3, // 0: clientappsfe.observability.v1.TelemetryEvent.dimensions:type_name -> clientappsfe.observability.v1.TelemetryEvent.DimensionsEntry + 4, // 1: clientappsfe.observability.v1.TelemetryEvent.measures:type_name -> clientappsfe.observability.v1.TelemetryEvent.MeasuresEntry + 0, // 2: clientappsfe.observability.v1.RecordEventsRequest.events:type_name -> clientappsfe.observability.v1.TelemetryEvent + 1, // 3: clientappsfe.observability.v1.TelemetryAPI.RecordEvents:input_type -> clientappsfe.observability.v1.RecordEventsRequest + 2, // 4: clientappsfe.observability.v1.TelemetryAPI.RecordEvents:output_type -> clientappsfe.observability.v1.RecordEventsResponse + 4, // [4:5] is the sub-list for method output_type + 3, // [3:4] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_observability_v1_telemetry_proto_init() } +func file_observability_v1_telemetry_proto_init() { + if File_observability_v1_telemetry_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_observability_v1_telemetry_proto_rawDesc), len(file_observability_v1_telemetry_proto_rawDesc)), + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_observability_v1_telemetry_proto_goTypes, + DependencyIndexes: file_observability_v1_telemetry_proto_depIdxs, + MessageInfos: file_observability_v1_telemetry_proto_msgTypes, + }.Build() + File_observability_v1_telemetry_proto = out.File + file_observability_v1_telemetry_proto_goTypes = nil + file_observability_v1_telemetry_proto_depIdxs = nil +} diff --git a/internal/barista/observability/telemetry.twirp.go b/internal/barista/observability/telemetry.twirp.go new file mode 100644 index 00000000000..0068d6ca212 --- /dev/null +++ b/internal/barista/observability/telemetry.twirp.go @@ -0,0 +1,1117 @@ +// Code generated by protoc-gen-twirp v8.1.3, DO NOT EDIT. +// source: observability/v1/telemetry.proto + +package observability + +import context "context" +import fmt "fmt" +import http "net/http" +import io "io" +import json "encoding/json" +import strconv "strconv" +import strings "strings" + +import protojson "google.golang.org/protobuf/encoding/protojson" +import proto "google.golang.org/protobuf/proto" +import twirp "github.com/twitchtv/twirp" +import ctxsetters "github.com/twitchtv/twirp/ctxsetters" + +import bytes "bytes" +import errors "errors" +import path "path" +import url "net/url" + +// Version compatibility assertion. +// If the constant is not defined in the package, that likely means +// the package needs to be updated to work with this generated code. +// See https://twitchtv.github.io/twirp/docs/version_matrix.html +const _ = twirp.TwirpPackageMinVersion_8_1_0 + +// ====================== +// TelemetryAPI Interface +// ====================== + +// TelemetryAPI receives telemetry events from client applications. +// This endpoint is unauthenticated to support anonymous telemetry collection. +type TelemetryAPI interface { + // RecordEvents records a batch of telemetry events from a client application. + RecordEvents(context.Context, *RecordEventsRequest) (*RecordEventsResponse, error) +} + +// ============================ +// TelemetryAPI Protobuf Client +// ============================ + +type telemetryAPIProtobufClient struct { + client HTTPClient + urls [1]string + interceptor twirp.Interceptor + opts twirp.ClientOptions +} + +// NewTelemetryAPIProtobufClient creates a Protobuf client that implements the TelemetryAPI interface. +// It communicates using Protobuf and can be configured with a custom HTTPClient. +func NewTelemetryAPIProtobufClient(baseURL string, client HTTPClient, opts ...twirp.ClientOption) TelemetryAPI { + if c, ok := client.(*http.Client); ok { + client = withoutRedirects(c) + } + + clientOpts := twirp.ClientOptions{} + for _, o := range opts { + o(&clientOpts) + } + + // Using ReadOpt allows backwards and forwards compatibility with new options in the future + literalURLs := false + _ = clientOpts.ReadOpt("literalURLs", &literalURLs) + var pathPrefix string + if ok := clientOpts.ReadOpt("pathPrefix", &pathPrefix); !ok { + pathPrefix = "/twirp" // default prefix + } + + // Build method URLs: []/./ + serviceURL := sanitizeBaseURL(baseURL) + serviceURL += baseServicePath(pathPrefix, "clientappsfe.observability.v1", "TelemetryAPI") + urls := [1]string{ + serviceURL + "RecordEvents", + } + + return &telemetryAPIProtobufClient{ + client: client, + urls: urls, + interceptor: twirp.ChainInterceptors(clientOpts.Interceptors...), + opts: clientOpts, + } +} + +func (c *telemetryAPIProtobufClient) RecordEvents(ctx context.Context, in *RecordEventsRequest) (*RecordEventsResponse, error) { + ctx = ctxsetters.WithPackageName(ctx, "clientappsfe.observability.v1") + ctx = ctxsetters.WithServiceName(ctx, "TelemetryAPI") + ctx = ctxsetters.WithMethodName(ctx, "RecordEvents") + caller := c.callRecordEvents + if c.interceptor != nil { + caller = func(ctx context.Context, req *RecordEventsRequest) (*RecordEventsResponse, error) { + resp, err := c.interceptor( + func(ctx context.Context, req interface{}) (interface{}, error) { + typedReq, ok := req.(*RecordEventsRequest) + if !ok { + return nil, twirp.InternalError("failed type assertion req.(*RecordEventsRequest) when calling interceptor") + } + return c.callRecordEvents(ctx, typedReq) + }, + )(ctx, req) + if resp != nil { + typedResp, ok := resp.(*RecordEventsResponse) + if !ok { + return nil, twirp.InternalError("failed type assertion resp.(*RecordEventsResponse) when calling interceptor") + } + return typedResp, err + } + return nil, err + } + } + return caller(ctx, in) +} + +func (c *telemetryAPIProtobufClient) callRecordEvents(ctx context.Context, in *RecordEventsRequest) (*RecordEventsResponse, error) { + out := new(RecordEventsResponse) + ctx, err := doProtobufRequest(ctx, c.client, c.opts.Hooks, c.urls[0], in, out) + if err != nil { + twerr, ok := err.(twirp.Error) + if !ok { + twerr = twirp.InternalErrorWith(err) + } + callClientError(ctx, c.opts.Hooks, twerr) + return nil, err + } + + callClientResponseReceived(ctx, c.opts.Hooks) + + return out, nil +} + +// ======================== +// TelemetryAPI JSON Client +// ======================== + +type telemetryAPIJSONClient struct { + client HTTPClient + urls [1]string + interceptor twirp.Interceptor + opts twirp.ClientOptions +} + +// NewTelemetryAPIJSONClient creates a JSON client that implements the TelemetryAPI interface. +// It communicates using JSON and can be configured with a custom HTTPClient. +func NewTelemetryAPIJSONClient(baseURL string, client HTTPClient, opts ...twirp.ClientOption) TelemetryAPI { + if c, ok := client.(*http.Client); ok { + client = withoutRedirects(c) + } + + clientOpts := twirp.ClientOptions{} + for _, o := range opts { + o(&clientOpts) + } + + // Using ReadOpt allows backwards and forwards compatibility with new options in the future + literalURLs := false + _ = clientOpts.ReadOpt("literalURLs", &literalURLs) + var pathPrefix string + if ok := clientOpts.ReadOpt("pathPrefix", &pathPrefix); !ok { + pathPrefix = "/twirp" // default prefix + } + + // Build method URLs: []/./ + serviceURL := sanitizeBaseURL(baseURL) + serviceURL += baseServicePath(pathPrefix, "clientappsfe.observability.v1", "TelemetryAPI") + urls := [1]string{ + serviceURL + "RecordEvents", + } + + return &telemetryAPIJSONClient{ + client: client, + urls: urls, + interceptor: twirp.ChainInterceptors(clientOpts.Interceptors...), + opts: clientOpts, + } +} + +func (c *telemetryAPIJSONClient) RecordEvents(ctx context.Context, in *RecordEventsRequest) (*RecordEventsResponse, error) { + ctx = ctxsetters.WithPackageName(ctx, "clientappsfe.observability.v1") + ctx = ctxsetters.WithServiceName(ctx, "TelemetryAPI") + ctx = ctxsetters.WithMethodName(ctx, "RecordEvents") + caller := c.callRecordEvents + if c.interceptor != nil { + caller = func(ctx context.Context, req *RecordEventsRequest) (*RecordEventsResponse, error) { + resp, err := c.interceptor( + func(ctx context.Context, req interface{}) (interface{}, error) { + typedReq, ok := req.(*RecordEventsRequest) + if !ok { + return nil, twirp.InternalError("failed type assertion req.(*RecordEventsRequest) when calling interceptor") + } + return c.callRecordEvents(ctx, typedReq) + }, + )(ctx, req) + if resp != nil { + typedResp, ok := resp.(*RecordEventsResponse) + if !ok { + return nil, twirp.InternalError("failed type assertion resp.(*RecordEventsResponse) when calling interceptor") + } + return typedResp, err + } + return nil, err + } + } + return caller(ctx, in) +} + +func (c *telemetryAPIJSONClient) callRecordEvents(ctx context.Context, in *RecordEventsRequest) (*RecordEventsResponse, error) { + out := new(RecordEventsResponse) + ctx, err := doJSONRequest(ctx, c.client, c.opts.Hooks, c.urls[0], in, out) + if err != nil { + twerr, ok := err.(twirp.Error) + if !ok { + twerr = twirp.InternalErrorWith(err) + } + callClientError(ctx, c.opts.Hooks, twerr) + return nil, err + } + + callClientResponseReceived(ctx, c.opts.Hooks) + + return out, nil +} + +// =========================== +// TelemetryAPI Server Handler +// =========================== + +type telemetryAPIServer struct { + TelemetryAPI + interceptor twirp.Interceptor + hooks *twirp.ServerHooks + pathPrefix string // prefix for routing + jsonSkipDefaults bool // do not include unpopulated fields (default values) in the response + jsonCamelCase bool // JSON fields are serialized as lowerCamelCase rather than keeping the original proto names +} + +// NewTelemetryAPIServer builds a TwirpServer that can be used as an http.Handler to handle +// HTTP requests that are routed to the right method in the provided svc implementation. +// The opts are twirp.ServerOption modifiers, for example twirp.WithServerHooks(hooks). +func NewTelemetryAPIServer(svc TelemetryAPI, opts ...interface{}) TwirpServer { + serverOpts := newServerOpts(opts) + + // Using ReadOpt allows backwards and forwards compatibility with new options in the future + jsonSkipDefaults := false + _ = serverOpts.ReadOpt("jsonSkipDefaults", &jsonSkipDefaults) + jsonCamelCase := false + _ = serverOpts.ReadOpt("jsonCamelCase", &jsonCamelCase) + var pathPrefix string + if ok := serverOpts.ReadOpt("pathPrefix", &pathPrefix); !ok { + pathPrefix = "/twirp" // default prefix + } + + return &telemetryAPIServer{ + TelemetryAPI: svc, + hooks: serverOpts.Hooks, + interceptor: twirp.ChainInterceptors(serverOpts.Interceptors...), + pathPrefix: pathPrefix, + jsonSkipDefaults: jsonSkipDefaults, + jsonCamelCase: jsonCamelCase, + } +} + +// writeError writes an HTTP response with a valid Twirp error format, and triggers hooks. +// If err is not a twirp.Error, it will get wrapped with twirp.InternalErrorWith(err) +func (s *telemetryAPIServer) writeError(ctx context.Context, resp http.ResponseWriter, err error) { + writeError(ctx, resp, err, s.hooks) +} + +// handleRequestBodyError is used to handle error when the twirp server cannot read request +func (s *telemetryAPIServer) handleRequestBodyError(ctx context.Context, resp http.ResponseWriter, msg string, err error) { + if context.Canceled == ctx.Err() { + s.writeError(ctx, resp, twirp.NewError(twirp.Canceled, "failed to read request: context canceled")) + return + } + if context.DeadlineExceeded == ctx.Err() { + s.writeError(ctx, resp, twirp.NewError(twirp.DeadlineExceeded, "failed to read request: deadline exceeded")) + return + } + s.writeError(ctx, resp, twirp.WrapError(malformedRequestError(msg), err)) +} + +// TelemetryAPIPathPrefix is a convenience constant that may identify URL paths. +// Should be used with caution, it only matches routes generated by Twirp Go clients, +// with the default "/twirp" prefix and default CamelCase service and method names. +// More info: https://twitchtv.github.io/twirp/docs/routing.html +const TelemetryAPIPathPrefix = "/twirp/clientappsfe.observability.v1.TelemetryAPI/" + +func (s *telemetryAPIServer) ServeHTTP(resp http.ResponseWriter, req *http.Request) { + ctx := req.Context() + ctx = ctxsetters.WithPackageName(ctx, "clientappsfe.observability.v1") + ctx = ctxsetters.WithServiceName(ctx, "TelemetryAPI") + ctx = ctxsetters.WithResponseWriter(ctx, resp) + + var err error + ctx, err = callRequestReceived(ctx, s.hooks) + if err != nil { + s.writeError(ctx, resp, err) + return + } + + if req.Method != "POST" { + msg := fmt.Sprintf("unsupported method %q (only POST is allowed)", req.Method) + s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path)) + return + } + + // Verify path format: []/./ + prefix, pkgService, method := parseTwirpPath(req.URL.Path) + if pkgService != "clientappsfe.observability.v1.TelemetryAPI" { + msg := fmt.Sprintf("no handler for path %q", req.URL.Path) + s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path)) + return + } + if prefix != s.pathPrefix { + msg := fmt.Sprintf("invalid path prefix %q, expected %q, on path %q", prefix, s.pathPrefix, req.URL.Path) + s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path)) + return + } + + switch method { + case "RecordEvents": + s.serveRecordEvents(ctx, resp, req) + return + default: + msg := fmt.Sprintf("no handler for path %q", req.URL.Path) + s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path)) + return + } +} + +func (s *telemetryAPIServer) serveRecordEvents(ctx context.Context, resp http.ResponseWriter, req *http.Request) { + header := req.Header.Get("Content-Type") + i := strings.Index(header, ";") + if i == -1 { + i = len(header) + } + switch strings.TrimSpace(strings.ToLower(header[:i])) { + case "application/json": + s.serveRecordEventsJSON(ctx, resp, req) + case "application/protobuf": + s.serveRecordEventsProtobuf(ctx, resp, req) + default: + msg := fmt.Sprintf("unexpected Content-Type: %q", req.Header.Get("Content-Type")) + twerr := badRouteError(msg, req.Method, req.URL.Path) + s.writeError(ctx, resp, twerr) + } +} + +func (s *telemetryAPIServer) serveRecordEventsJSON(ctx context.Context, resp http.ResponseWriter, req *http.Request) { + var err error + ctx = ctxsetters.WithMethodName(ctx, "RecordEvents") + ctx, err = callRequestRouted(ctx, s.hooks) + if err != nil { + s.writeError(ctx, resp, err) + return + } + + d := json.NewDecoder(req.Body) + rawReqBody := json.RawMessage{} + if err := d.Decode(&rawReqBody); err != nil { + s.handleRequestBodyError(ctx, resp, "the json request could not be decoded", err) + return + } + reqContent := new(RecordEventsRequest) + unmarshaler := protojson.UnmarshalOptions{DiscardUnknown: true} + if err = unmarshaler.Unmarshal(rawReqBody, reqContent); err != nil { + s.handleRequestBodyError(ctx, resp, "the json request could not be decoded", err) + return + } + + handler := s.TelemetryAPI.RecordEvents + if s.interceptor != nil { + handler = func(ctx context.Context, req *RecordEventsRequest) (*RecordEventsResponse, error) { + resp, err := s.interceptor( + func(ctx context.Context, req interface{}) (interface{}, error) { + typedReq, ok := req.(*RecordEventsRequest) + if !ok { + return nil, twirp.InternalError("failed type assertion req.(*RecordEventsRequest) when calling interceptor") + } + return s.TelemetryAPI.RecordEvents(ctx, typedReq) + }, + )(ctx, req) + if resp != nil { + typedResp, ok := resp.(*RecordEventsResponse) + if !ok { + return nil, twirp.InternalError("failed type assertion resp.(*RecordEventsResponse) when calling interceptor") + } + return typedResp, err + } + return nil, err + } + } + + // Call service method + var respContent *RecordEventsResponse + func() { + defer ensurePanicResponses(ctx, resp, s.hooks) + respContent, err = handler(ctx, reqContent) + }() + + if err != nil { + s.writeError(ctx, resp, err) + return + } + if respContent == nil { + s.writeError(ctx, resp, twirp.InternalError("received a nil *RecordEventsResponse and nil error while calling RecordEvents. nil responses are not supported")) + return + } + + ctx = callResponsePrepared(ctx, s.hooks) + + marshaler := &protojson.MarshalOptions{UseProtoNames: !s.jsonCamelCase, EmitUnpopulated: !s.jsonSkipDefaults} + respBytes, err := marshaler.Marshal(respContent) + if err != nil { + s.writeError(ctx, resp, wrapInternal(err, "failed to marshal json response")) + return + } + + ctx = ctxsetters.WithStatusCode(ctx, http.StatusOK) + resp.Header().Set("Content-Type", "application/json") + resp.Header().Set("Content-Length", strconv.Itoa(len(respBytes))) + resp.WriteHeader(http.StatusOK) + + if n, err := resp.Write(respBytes); err != nil { + msg := fmt.Sprintf("failed to write response, %d of %d bytes written: %s", n, len(respBytes), err.Error()) + twerr := twirp.NewError(twirp.Unknown, msg) + ctx = callError(ctx, s.hooks, twerr) + } + callResponseSent(ctx, s.hooks) +} + +func (s *telemetryAPIServer) serveRecordEventsProtobuf(ctx context.Context, resp http.ResponseWriter, req *http.Request) { + var err error + ctx = ctxsetters.WithMethodName(ctx, "RecordEvents") + ctx, err = callRequestRouted(ctx, s.hooks) + if err != nil { + s.writeError(ctx, resp, err) + return + } + + buf, err := io.ReadAll(req.Body) + if err != nil { + s.handleRequestBodyError(ctx, resp, "failed to read request body", err) + return + } + reqContent := new(RecordEventsRequest) + if err = proto.Unmarshal(buf, reqContent); err != nil { + s.writeError(ctx, resp, malformedRequestError("the protobuf request could not be decoded")) + return + } + + handler := s.TelemetryAPI.RecordEvents + if s.interceptor != nil { + handler = func(ctx context.Context, req *RecordEventsRequest) (*RecordEventsResponse, error) { + resp, err := s.interceptor( + func(ctx context.Context, req interface{}) (interface{}, error) { + typedReq, ok := req.(*RecordEventsRequest) + if !ok { + return nil, twirp.InternalError("failed type assertion req.(*RecordEventsRequest) when calling interceptor") + } + return s.TelemetryAPI.RecordEvents(ctx, typedReq) + }, + )(ctx, req) + if resp != nil { + typedResp, ok := resp.(*RecordEventsResponse) + if !ok { + return nil, twirp.InternalError("failed type assertion resp.(*RecordEventsResponse) when calling interceptor") + } + return typedResp, err + } + return nil, err + } + } + + // Call service method + var respContent *RecordEventsResponse + func() { + defer ensurePanicResponses(ctx, resp, s.hooks) + respContent, err = handler(ctx, reqContent) + }() + + if err != nil { + s.writeError(ctx, resp, err) + return + } + if respContent == nil { + s.writeError(ctx, resp, twirp.InternalError("received a nil *RecordEventsResponse and nil error while calling RecordEvents. nil responses are not supported")) + return + } + + ctx = callResponsePrepared(ctx, s.hooks) + + respBytes, err := proto.Marshal(respContent) + if err != nil { + s.writeError(ctx, resp, wrapInternal(err, "failed to marshal proto response")) + return + } + + ctx = ctxsetters.WithStatusCode(ctx, http.StatusOK) + resp.Header().Set("Content-Type", "application/protobuf") + resp.Header().Set("Content-Length", strconv.Itoa(len(respBytes))) + resp.WriteHeader(http.StatusOK) + if n, err := resp.Write(respBytes); err != nil { + msg := fmt.Sprintf("failed to write response, %d of %d bytes written: %s", n, len(respBytes), err.Error()) + twerr := twirp.NewError(twirp.Unknown, msg) + ctx = callError(ctx, s.hooks, twerr) + } + callResponseSent(ctx, s.hooks) +} + +func (s *telemetryAPIServer) ServiceDescriptor() ([]byte, int) { + return twirpFileDescriptor0, 0 +} + +func (s *telemetryAPIServer) ProtocGenTwirpVersion() string { + return "v8.1.3" +} + +// PathPrefix returns the base service path, in the form: "//./" +// that is everything in a Twirp route except for the . This can be used for routing, +// for example to identify the requests that are targeted to this service in a mux. +func (s *telemetryAPIServer) PathPrefix() string { + return baseServicePath(s.pathPrefix, "clientappsfe.observability.v1", "TelemetryAPI") +} + +// ===== +// Utils +// ===== + +// HTTPClient is the interface used by generated clients to send HTTP requests. +// It is fulfilled by *(net/http).Client, which is sufficient for most users. +// Users can provide their own implementation for special retry policies. +// +// HTTPClient implementations should not follow redirects. Redirects are +// automatically disabled if *(net/http).Client is passed to client +// constructors. See the withoutRedirects function in this file for more +// details. +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// TwirpServer is the interface generated server structs will support: they're +// HTTP handlers with additional methods for accessing metadata about the +// service. Those accessors are a low-level API for building reflection tools. +// Most people can think of TwirpServers as just http.Handlers. +type TwirpServer interface { + http.Handler + + // ServiceDescriptor returns gzipped bytes describing the .proto file that + // this service was generated from. Once unzipped, the bytes can be + // unmarshalled as a + // google.golang.org/protobuf/types/descriptorpb.FileDescriptorProto. + // + // The returned integer is the index of this particular service within that + // FileDescriptorProto's 'Service' slice of ServiceDescriptorProtos. This is a + // low-level field, expected to be used for reflection. + ServiceDescriptor() ([]byte, int) + + // ProtocGenTwirpVersion is the semantic version string of the version of + // twirp used to generate this file. + ProtocGenTwirpVersion() string + + // PathPrefix returns the HTTP URL path prefix for all methods handled by this + // service. This can be used with an HTTP mux to route Twirp requests. + // The path prefix is in the form: "//./" + // that is, everything in a Twirp route except for the at the end. + PathPrefix() string +} + +func newServerOpts(opts []interface{}) *twirp.ServerOptions { + serverOpts := &twirp.ServerOptions{} + for _, opt := range opts { + switch o := opt.(type) { + case twirp.ServerOption: + o(serverOpts) + case *twirp.ServerHooks: // backwards compatibility, allow to specify hooks as an argument + twirp.WithServerHooks(o)(serverOpts) + case nil: // backwards compatibility, allow nil value for the argument + continue + default: + panic(fmt.Sprintf("Invalid option type %T, please use a twirp.ServerOption", o)) + } + } + return serverOpts +} + +// WriteError writes an HTTP response with a valid Twirp error format (code, msg, meta). +// Useful outside of the Twirp server (e.g. http middleware), but does not trigger hooks. +// If err is not a twirp.Error, it will get wrapped with twirp.InternalErrorWith(err) +func WriteError(resp http.ResponseWriter, err error) { + writeError(context.Background(), resp, err, nil) +} + +// writeError writes Twirp errors in the response and triggers hooks. +func writeError(ctx context.Context, resp http.ResponseWriter, err error, hooks *twirp.ServerHooks) { + // Convert to a twirp.Error. Non-twirp errors are converted to internal errors. + var twerr twirp.Error + if !errors.As(err, &twerr) { + twerr = twirp.InternalErrorWith(err) + } + + statusCode := twirp.ServerHTTPStatusFromErrorCode(twerr.Code()) + ctx = ctxsetters.WithStatusCode(ctx, statusCode) + ctx = callError(ctx, hooks, twerr) + + respBody := marshalErrorToJSON(twerr) + + resp.Header().Set("Content-Type", "application/json") // Error responses are always JSON + resp.Header().Set("Content-Length", strconv.Itoa(len(respBody))) + resp.WriteHeader(statusCode) // set HTTP status code and send response + + _, writeErr := resp.Write(respBody) + if writeErr != nil { + // We have three options here. We could log the error, call the Error + // hook, or just silently ignore the error. + // + // Logging is unacceptable because we don't have a user-controlled + // logger; writing out to stderr without permission is too rude. + // + // Calling the Error hook would confuse users: it would mean the Error + // hook got called twice for one request, which is likely to lead to + // duplicated log messages and metrics, no matter how well we document + // the behavior. + // + // Silently ignoring the error is our least-bad option. It's highly + // likely that the connection is broken and the original 'err' says + // so anyway. + _ = writeErr + } + + callResponseSent(ctx, hooks) +} + +// sanitizeBaseURL parses the the baseURL, and adds the "http" scheme if needed. +// If the URL is unparsable, the baseURL is returned unchanged. +func sanitizeBaseURL(baseURL string) string { + u, err := url.Parse(baseURL) + if err != nil { + return baseURL // invalid URL will fail later when making requests + } + if u.Scheme == "" { + u.Scheme = "http" + } + return u.String() +} + +// baseServicePath composes the path prefix for the service (without ). +// e.g.: baseServicePath("/twirp", "my.pkg", "MyService") +// +// returns => "/twirp/my.pkg.MyService/" +// +// e.g.: baseServicePath("", "", "MyService") +// +// returns => "/MyService/" +func baseServicePath(prefix, pkg, service string) string { + fullServiceName := service + if pkg != "" { + fullServiceName = pkg + "." + service + } + return path.Join("/", prefix, fullServiceName) + "/" +} + +// parseTwirpPath extracts path components form a valid Twirp route. +// Expected format: "[]/./" +// e.g.: prefix, pkgService, method := parseTwirpPath("/twirp/pkg.Svc/MakeHat") +func parseTwirpPath(path string) (string, string, string) { + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "", "", "" + } + method := parts[len(parts)-1] + pkgService := parts[len(parts)-2] + prefix := strings.Join(parts[0:len(parts)-2], "/") + return prefix, pkgService, method +} + +// getCustomHTTPReqHeaders retrieves a copy of any headers that are set in +// a context through the twirp.WithHTTPRequestHeaders function. +// If there are no headers set, or if they have the wrong type, nil is returned. +func getCustomHTTPReqHeaders(ctx context.Context) http.Header { + header, ok := twirp.HTTPRequestHeaders(ctx) + if !ok || header == nil { + return nil + } + copied := make(http.Header) + for k, vv := range header { + if vv == nil { + copied[k] = nil + continue + } + copied[k] = make([]string, len(vv)) + copy(copied[k], vv) + } + return copied +} + +// newRequest makes an http.Request from a client, adding common headers. +func newRequest(ctx context.Context, url string, reqBody io.Reader, contentType string) (*http.Request, error) { + req, err := http.NewRequest("POST", url, reqBody) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if customHeader := getCustomHTTPReqHeaders(ctx); customHeader != nil { + req.Header = customHeader + } + req.Header.Set("Accept", contentType) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Twirp-Version", "v8.1.3") + return req, nil +} + +// JSON serialization for errors +type twerrJSON struct { + Code string `json:"code"` + Msg string `json:"msg"` + Meta map[string]string `json:"meta,omitempty"` +} + +// marshalErrorToJSON returns JSON from a twirp.Error, that can be used as HTTP error response body. +// If serialization fails, it will use a descriptive Internal error instead. +func marshalErrorToJSON(twerr twirp.Error) []byte { + // make sure that msg is not too large + msg := twerr.Msg() + if len(msg) > 1e6 { + msg = msg[:1e6] + } + + tj := twerrJSON{ + Code: string(twerr.Code()), + Msg: msg, + Meta: twerr.MetaMap(), + } + + buf, err := json.Marshal(&tj) + if err != nil { + buf = []byte("{\"type\": \"" + twirp.Internal + "\", \"msg\": \"There was an error but it could not be serialized into JSON\"}") // fallback + } + + return buf +} + +// errorFromResponse builds a twirp.Error from a non-200 HTTP response. +// If the response has a valid serialized Twirp error, then it's returned. +// If not, the response status code is used to generate a similar twirp +// error. See twirpErrorFromIntermediary for more info on intermediary errors. +func errorFromResponse(resp *http.Response) twirp.Error { + statusCode := resp.StatusCode + statusText := http.StatusText(statusCode) + + if isHTTPRedirect(statusCode) { + // Unexpected redirect: it must be an error from an intermediary. + // Twirp clients don't follow redirects automatically, Twirp only handles + // POST requests, redirects should only happen on GET and HEAD requests. + location := resp.Header.Get("Location") + msg := fmt.Sprintf("unexpected HTTP status code %d %q received, Location=%q", statusCode, statusText, location) + return twirpErrorFromIntermediary(statusCode, msg, location) + } + + respBodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return wrapInternal(err, "failed to read server error response body") + } + + var tj twerrJSON + dec := json.NewDecoder(bytes.NewReader(respBodyBytes)) + dec.DisallowUnknownFields() + if err := dec.Decode(&tj); err != nil || tj.Code == "" { + // Invalid JSON response; it must be an error from an intermediary. + msg := fmt.Sprintf("Error from intermediary with HTTP status code %d %q", statusCode, statusText) + return twirpErrorFromIntermediary(statusCode, msg, string(respBodyBytes)) + } + + errorCode := twirp.ErrorCode(tj.Code) + if !twirp.IsValidErrorCode(errorCode) { + msg := "invalid type returned from server error response: " + tj.Code + return twirp.InternalError(msg).WithMeta("body", string(respBodyBytes)) + } + + twerr := twirp.NewError(errorCode, tj.Msg) + for k, v := range tj.Meta { + twerr = twerr.WithMeta(k, v) + } + return twerr +} + +// twirpErrorFromIntermediary maps HTTP errors from non-twirp sources to twirp errors. +// The mapping is similar to gRPC: https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md. +// Returned twirp Errors have some additional metadata for inspection. +func twirpErrorFromIntermediary(status int, msg string, bodyOrLocation string) twirp.Error { + var code twirp.ErrorCode + if isHTTPRedirect(status) { // 3xx + code = twirp.Internal + } else { + switch status { + case 400: // Bad Request + code = twirp.Internal + case 401: // Unauthorized + code = twirp.Unauthenticated + case 403: // Forbidden + code = twirp.PermissionDenied + case 404: // Not Found + code = twirp.BadRoute + case 429: // Too Many Requests + code = twirp.ResourceExhausted + case 502, 503, 504: // Bad Gateway, Service Unavailable, Gateway Timeout + code = twirp.Unavailable + default: // All other codes + code = twirp.Unknown + } + } + + twerr := twirp.NewError(code, msg) + twerr = twerr.WithMeta("http_error_from_intermediary", "true") // to easily know if this error was from intermediary + twerr = twerr.WithMeta("status_code", strconv.Itoa(status)) + if isHTTPRedirect(status) { + twerr = twerr.WithMeta("location", bodyOrLocation) + } else { + twerr = twerr.WithMeta("body", bodyOrLocation) + } + return twerr +} + +func isHTTPRedirect(status int) bool { + return status >= 300 && status <= 399 +} + +// wrapInternal wraps an error with a prefix as an Internal error. +// The original error cause is accessible by github.com/pkg/errors.Cause. +func wrapInternal(err error, prefix string) twirp.Error { + return twirp.InternalErrorWith(&wrappedError{prefix: prefix, cause: err}) +} + +type wrappedError struct { + prefix string + cause error +} + +func (e *wrappedError) Error() string { return e.prefix + ": " + e.cause.Error() } +func (e *wrappedError) Unwrap() error { return e.cause } // for go1.13 + errors.Is/As +func (e *wrappedError) Cause() error { return e.cause } // for github.com/pkg/errors + +// ensurePanicResponses makes sure that rpc methods causing a panic still result in a Twirp Internal +// error response (status 500), and error hooks are properly called with the panic wrapped as an error. +// The panic is re-raised so it can be handled normally with middleware. +func ensurePanicResponses(ctx context.Context, resp http.ResponseWriter, hooks *twirp.ServerHooks) { + if r := recover(); r != nil { + // Wrap the panic as an error so it can be passed to error hooks. + // The original error is accessible from error hooks, but not visible in the response. + err := errFromPanic(r) + twerr := &internalWithCause{msg: "Internal service panic", cause: err} + // Actually write the error + writeError(ctx, resp, twerr, hooks) + // If possible, flush the error to the wire. + f, ok := resp.(http.Flusher) + if ok { + f.Flush() + } + + panic(r) + } +} + +// errFromPanic returns the typed error if the recovered panic is an error, otherwise formats as error. +func errFromPanic(p interface{}) error { + if err, ok := p.(error); ok { + return err + } + return fmt.Errorf("panic: %v", p) +} + +// internalWithCause is a Twirp Internal error wrapping an original error cause, +// but the original error message is not exposed on Msg(). The original error +// can be checked with go1.13+ errors.Is/As, and also by (github.com/pkg/errors).Unwrap +type internalWithCause struct { + msg string + cause error +} + +func (e *internalWithCause) Unwrap() error { return e.cause } // for go1.13 + errors.Is/As +func (e *internalWithCause) Cause() error { return e.cause } // for github.com/pkg/errors +func (e *internalWithCause) Error() string { return e.msg + ": " + e.cause.Error() } +func (e *internalWithCause) Code() twirp.ErrorCode { return twirp.Internal } +func (e *internalWithCause) Msg() string { return e.msg } +func (e *internalWithCause) Meta(key string) string { return "" } +func (e *internalWithCause) MetaMap() map[string]string { return nil } +func (e *internalWithCause) WithMeta(key string, val string) twirp.Error { return e } + +// malformedRequestError is used when the twirp server cannot unmarshal a request +func malformedRequestError(msg string) twirp.Error { + return twirp.NewError(twirp.Malformed, msg) +} + +// badRouteError is used when the twirp server cannot route a request +func badRouteError(msg string, method, url string) twirp.Error { + err := twirp.NewError(twirp.BadRoute, msg) + err = err.WithMeta("twirp_invalid_route", method+" "+url) + return err +} + +// withoutRedirects makes sure that the POST request can not be redirected. +// The standard library will, by default, redirect requests (including POSTs) if it gets a 302 or +// 303 response, and also 301s in go1.8. It redirects by making a second request, changing the +// method to GET and removing the body. This produces very confusing error messages, so instead we +// set a redirect policy that always errors. This stops Go from executing the redirect. +// +// We have to be a little careful in case the user-provided http.Client has its own CheckRedirect +// policy - if so, we'll run through that policy first. +// +// Because this requires modifying the http.Client, we make a new copy of the client and return it. +func withoutRedirects(in *http.Client) *http.Client { + copy := *in + copy.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if in.CheckRedirect != nil { + // Run the input's redirect if it exists, in case it has side effects, but ignore any error it + // returns, since we want to use ErrUseLastResponse. + err := in.CheckRedirect(req, via) + _ = err // Silly, but this makes sure generated code passes errcheck -blank, which some people use. + } + return http.ErrUseLastResponse + } + return © +} + +// doProtobufRequest makes a Protobuf request to the remote Twirp service. +func doProtobufRequest(ctx context.Context, client HTTPClient, hooks *twirp.ClientHooks, url string, in, out proto.Message) (_ context.Context, err error) { + reqBodyBytes, err := proto.Marshal(in) + if err != nil { + return ctx, wrapInternal(err, "failed to marshal proto request") + } + reqBody := bytes.NewBuffer(reqBodyBytes) + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + req, err := newRequest(ctx, url, reqBody, "application/protobuf") + if err != nil { + return ctx, wrapInternal(err, "could not build request") + } + ctx, err = callClientRequestPrepared(ctx, hooks, req) + if err != nil { + return ctx, err + } + + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return ctx, wrapInternal(err, "failed to do request") + } + defer func() { _ = resp.Body.Close() }() + + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + if resp.StatusCode != 200 { + return ctx, errorFromResponse(resp) + } + + respBodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return ctx, wrapInternal(err, "failed to read response body") + } + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + if err = proto.Unmarshal(respBodyBytes, out); err != nil { + return ctx, wrapInternal(err, "failed to unmarshal proto response") + } + return ctx, nil +} + +// doJSONRequest makes a JSON request to the remote Twirp service. +func doJSONRequest(ctx context.Context, client HTTPClient, hooks *twirp.ClientHooks, url string, in, out proto.Message) (_ context.Context, err error) { + marshaler := &protojson.MarshalOptions{UseProtoNames: true} + reqBytes, err := marshaler.Marshal(in) + if err != nil { + return ctx, wrapInternal(err, "failed to marshal json request") + } + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + req, err := newRequest(ctx, url, bytes.NewReader(reqBytes), "application/json") + if err != nil { + return ctx, wrapInternal(err, "could not build request") + } + ctx, err = callClientRequestPrepared(ctx, hooks, req) + if err != nil { + return ctx, err + } + + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return ctx, wrapInternal(err, "failed to do request") + } + + defer func() { + cerr := resp.Body.Close() + if err == nil && cerr != nil { + err = wrapInternal(cerr, "failed to close response body") + } + }() + + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + if resp.StatusCode != 200 { + return ctx, errorFromResponse(resp) + } + + d := json.NewDecoder(resp.Body) + rawRespBody := json.RawMessage{} + if err := d.Decode(&rawRespBody); err != nil { + return ctx, wrapInternal(err, "failed to unmarshal json response") + } + unmarshaler := protojson.UnmarshalOptions{DiscardUnknown: true} + if err = unmarshaler.Unmarshal(rawRespBody, out); err != nil { + return ctx, wrapInternal(err, "failed to unmarshal json response") + } + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + return ctx, nil +} + +// Call twirp.ServerHooks.RequestReceived if the hook is available +func callRequestReceived(ctx context.Context, h *twirp.ServerHooks) (context.Context, error) { + if h == nil || h.RequestReceived == nil { + return ctx, nil + } + return h.RequestReceived(ctx) +} + +// Call twirp.ServerHooks.RequestRouted if the hook is available +func callRequestRouted(ctx context.Context, h *twirp.ServerHooks) (context.Context, error) { + if h == nil || h.RequestRouted == nil { + return ctx, nil + } + return h.RequestRouted(ctx) +} + +// Call twirp.ServerHooks.ResponsePrepared if the hook is available +func callResponsePrepared(ctx context.Context, h *twirp.ServerHooks) context.Context { + if h == nil || h.ResponsePrepared == nil { + return ctx + } + return h.ResponsePrepared(ctx) +} + +// Call twirp.ServerHooks.ResponseSent if the hook is available +func callResponseSent(ctx context.Context, h *twirp.ServerHooks) { + if h == nil || h.ResponseSent == nil { + return + } + h.ResponseSent(ctx) +} + +// Call twirp.ServerHooks.Error if the hook is available +func callError(ctx context.Context, h *twirp.ServerHooks, err twirp.Error) context.Context { + if h == nil || h.Error == nil { + return ctx + } + return h.Error(ctx, err) +} + +func callClientResponseReceived(ctx context.Context, h *twirp.ClientHooks) { + if h == nil || h.ResponseReceived == nil { + return + } + h.ResponseReceived(ctx) +} + +func callClientRequestPrepared(ctx context.Context, h *twirp.ClientHooks, req *http.Request) (context.Context, error) { + if h == nil || h.RequestPrepared == nil { + return ctx, nil + } + return h.RequestPrepared(ctx, req) +} + +func callClientError(ctx context.Context, h *twirp.ClientHooks, err twirp.Error) { + if h == nil || h.Error == nil { + return + } + h.Error(ctx, err) +} + +var twirpFileDescriptor0 = []byte{ + // 353 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x94, 0x92, 0x4d, 0x4b, 0x02, 0x41, + 0x18, 0xc7, 0x59, 0xb7, 0x24, 0x9f, 0xec, 0x85, 0x49, 0x62, 0x11, 0x04, 0xf1, 0xe4, 0xa5, 0x1d, + 0xd4, 0x4b, 0x24, 0x1e, 0x8a, 0x3c, 0x44, 0x08, 0xb1, 0x08, 0x41, 0x14, 0xb1, 0xab, 0x4f, 0x36, + 0xb8, 0x2f, 0xd3, 0xce, 0xec, 0xca, 0x7c, 0x82, 0x3e, 0x71, 0xf7, 0x70, 0x56, 0x65, 0x57, 0x22, + 0xf1, 0x36, 0x3b, 0x33, 0xbf, 0xdf, 0xff, 0xf9, 0x2f, 0x03, 0xcd, 0xc8, 0x13, 0x18, 0xa7, 0xae, + 0xc7, 0x7c, 0x26, 0x15, 0x4d, 0x3b, 0x54, 0xa2, 0x8f, 0x01, 0xca, 0x58, 0xd9, 0x3c, 0x8e, 0x64, + 0x44, 0x1a, 0x13, 0x9f, 0x61, 0x28, 0x5d, 0xce, 0xc5, 0x07, 0xda, 0x85, 0xeb, 0x76, 0xda, 0x69, + 0xfd, 0x94, 0xe0, 0x74, 0xbc, 0x46, 0x86, 0x29, 0x86, 0x92, 0x9c, 0x83, 0xe9, 0x72, 0x6e, 0x19, + 0x4d, 0xa3, 0x5d, 0x71, 0x96, 0x4b, 0xd2, 0x00, 0xc0, 0xe5, 0xd1, 0xbb, 0x54, 0x1c, 0xad, 0x92, + 0x3e, 0xa8, 0xe8, 0x9d, 0xb1, 0xe2, 0x48, 0xde, 0x00, 0xa6, 0x2c, 0xc0, 0x50, 0xb0, 0x28, 0x14, + 0x96, 0xd9, 0x34, 0xdb, 0xc7, 0xdd, 0x81, 0xfd, 0x6f, 0xae, 0x5d, 0xcc, 0xb4, 0xef, 0x37, 0xfc, + 0x30, 0x94, 0xb1, 0x72, 0x72, 0x42, 0xf2, 0x0c, 0x47, 0x01, 0xba, 0x22, 0x89, 0x51, 0x58, 0x07, + 0x5a, 0xde, 0xdf, 0x4f, 0x3e, 0x5a, 0xd1, 0x99, 0x7a, 0x23, 0xab, 0x0f, 0xe0, 0x6c, 0x2b, 0x77, + 0xd9, 0x7d, 0x8e, 0x6a, 0xdd, 0x7d, 0x8e, 0x8a, 0xd4, 0xe0, 0x30, 0x75, 0xfd, 0x64, 0x5d, 0x3b, + 0xfb, 0xb8, 0x29, 0x5d, 0x1b, 0xf5, 0x3e, 0x9c, 0x14, 0xcc, 0xbb, 0x60, 0x33, 0x07, 0xb7, 0x5e, + 0xe1, 0xc2, 0xc1, 0x49, 0x14, 0x4f, 0xf5, 0x88, 0xc2, 0xc1, 0xaf, 0x04, 0x85, 0x24, 0x43, 0x28, + 0xeb, 0xff, 0x2a, 0x2c, 0x43, 0x37, 0xbd, 0xda, 0xab, 0xa9, 0xb3, 0x82, 0x5b, 0x97, 0x50, 0x2b, + 0xda, 0x05, 0x8f, 0x42, 0x81, 0xdd, 0x6f, 0x03, 0xaa, 0x1b, 0xe4, 0xf6, 0xe9, 0x81, 0x2c, 0xa0, + 0x9a, 0xbf, 0x48, 0xba, 0x3b, 0xf2, 0xfe, 0x98, 0xb9, 0xde, 0xdb, 0x8b, 0xc9, 0x26, 0xb9, 0x1b, + 0xbd, 0x3c, 0xce, 0x98, 0xfc, 0x4c, 0x3c, 0x7b, 0x12, 0x05, 0x34, 0x5b, 0xd2, 0xbc, 0x87, 0xf2, + 0xf9, 0x8c, 0xba, 0x9c, 0x51, 0xb9, 0x60, 0x31, 0xa7, 0xdb, 0xef, 0xbc, 0x5f, 0xd8, 0xf0, 0xca, + 0xfa, 0xb1, 0xf7, 0x7e, 0x03, 0x00, 0x00, 0xff, 0xff, 0xff, 0x5b, 0x87, 0x22, 0x10, 0x03, 0x00, + 0x00, +} diff --git a/internal/config/config.go b/internal/config/config.go index 4d83bd4e5a1..a694ca65461 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,6 +31,7 @@ const ( promptKey = "prompt" preferEditorPromptKey = "prefer_editor_prompt" spinnerKey = "spinner" + telemetryKey = "telemetry" userKey = "user" usersKey = "users" versionKey = "version" @@ -169,6 +170,11 @@ func (c *cfg) Spinner(hostname string) gh.ConfigEntry { return c.GetOrDefault(hostname, spinnerKey).Unwrap() } +func (c *cfg) Telemetry() gh.ConfigEntry { + // Intentionally panic if there is no user provided value or default value (which would be a programmer error) + return c.GetOrDefault("", telemetryKey).Unwrap() +} + func (c *cfg) Version() o.Option[string] { return c.get("", versionKey) } @@ -682,6 +688,15 @@ var Options = []ConfigOption{ return c.Spinner(hostname).Value }, }, + { + Key: telemetryKey, + Description: "whether telemetry is enabled, disabled, or logging", + DefaultValue: "enabled", + AllowedValues: []string{"enabled", "disabled", "log"}, + CurrentValue: func(c gh.Config, hostname string) string { + return c.Telemetry().Value + }, + }, } func HomeDirPath(subdir string) (string, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 67a9a98d1ab..57cca23740f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -182,3 +182,34 @@ func TestSetUserSpecificKeyNoUserPresent(t *testing.T) { requireKeyWithValue(t, c.cfg, []string{hostsKey, host, key}, val) requireNoKey(t, c.cfg, []string{hostsKey, host, usersKey}) } + +func TestTelemetry(t *testing.T) { + t.Run("returns default when not configured", func(t *testing.T) { + c := newTestConfig() + + entry := c.Telemetry() + + require.Equal(t, "enabled", entry.Value) + require.Equal(t, gh.ConfigDefaultProvided, entry.Source) + }) + + t.Run("returns user configured value", func(t *testing.T) { + c := newTestConfig() + c.Set("", telemetryKey, "disabled") + + entry := c.Telemetry() + + require.Equal(t, "disabled", entry.Value) + require.Equal(t, gh.ConfigUserProvided, entry.Source) + }) + + t.Run("returns log when configured", func(t *testing.T) { + c := newTestConfig() + c.Set("", telemetryKey, "log") + + entry := c.Telemetry() + + require.Equal(t, "log", entry.Value) + require.Equal(t, gh.ConfigUserProvided, entry.Source) + }) +} diff --git a/internal/config/stub.go b/internal/config/stub.go index ea60254db85..fe5e277b62b 100644 --- a/internal/config/stub.go +++ b/internal/config/stub.go @@ -61,6 +61,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock { mock.BrowserFunc = func(hostname string) gh.ConfigEntry { return cfg.Browser(hostname) } + mock.TelemetryFunc = func() gh.ConfigEntry { + return cfg.Telemetry() + } mock.ColorLabelsFunc = func(hostname string) gh.ConfigEntry { return cfg.ColorLabels(hostname) } diff --git a/internal/gh/gh.go b/internal/gh/gh.go index aa90a5268b6..759a931f2b7 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -57,6 +57,8 @@ type Config interface { PreferEditorPrompt(hostname string) ConfigEntry // Spinner returns the configured spinner setting, optionally scoped by host. Spinner(hostname string) ConfigEntry + // Telemetry returns the configured telemetry setting, ignoring host scoping since telemetry is a global setting. + Telemetry() ConfigEntry // Aliases provides persistent storage and modification of command aliases. Aliases() AliasConfig diff --git a/internal/gh/ghtelemetry/telemetry.go b/internal/gh/ghtelemetry/telemetry.go new file mode 100644 index 00000000000..c9256361b59 --- /dev/null +++ b/internal/gh/ghtelemetry/telemetry.go @@ -0,0 +1,27 @@ +package ghtelemetry + +type Dimensions map[string]string + +type Measures map[string]int64 + +type Event struct { + Type string + Dimensions Dimensions + Measures Measures +} + +type EventRecorder interface { + Record(event Event) +} + +type CommandRecorder interface { + EventRecorder + SetSampleRate(rate int) +} + +type Service interface { + CommandRecorder + Flush() +} + +const SAMPLE_ALL = 100 diff --git a/internal/gh/mock/config.go b/internal/gh/mock/config.go index 9f3f807993b..31e35cb1899 100644 --- a/internal/gh/mock/config.go +++ b/internal/gh/mock/config.go @@ -4,9 +4,10 @@ package ghmock import ( + "sync" + "github.com/cli/cli/v2/internal/gh" o "github.com/cli/cli/v2/pkg/option" - "sync" ) // Ensure, that ConfigMock does implement gh.Config. @@ -70,6 +71,9 @@ var _ gh.Config = &ConfigMock{} // SpinnerFunc: func(hostname string) gh.ConfigEntry { // panic("mock out the Spinner method") // }, +// TelemetryFunc: func() gh.ConfigEntry { +// panic("mock out the Telemetry method") +// }, // VersionFunc: func() o.Option[string] { // panic("mock out the Version method") // }, @@ -134,6 +138,9 @@ type ConfigMock struct { // SpinnerFunc mocks the Spinner method. SpinnerFunc func(hostname string) gh.ConfigEntry + // TelemetryFunc mocks the Telemetry method. + TelemetryFunc func() gh.ConfigEntry + // VersionFunc mocks the Version method. VersionFunc func() o.Option[string] @@ -227,6 +234,9 @@ type ConfigMock struct { // Hostname is the hostname argument value. Hostname string } + // Telemetry holds details about calls to the Telemetry method. + Telemetry []struct { + } // Version holds details about calls to the Version method. Version []struct { } @@ -251,6 +261,7 @@ type ConfigMock struct { lockPrompt sync.RWMutex lockSet sync.RWMutex lockSpinner sync.RWMutex + lockTelemetry sync.RWMutex lockVersion sync.RWMutex lockWrite sync.RWMutex } @@ -796,6 +807,33 @@ func (mock *ConfigMock) SpinnerCalls() []struct { return calls } +// Telemetry calls TelemetryFunc. +func (mock *ConfigMock) Telemetry() gh.ConfigEntry { + if mock.TelemetryFunc == nil { + panic("ConfigMock.TelemetryFunc: method is nil but Config.Telemetry was just called") + } + callInfo := struct { + }{} + mock.lockTelemetry.Lock() + mock.calls.Telemetry = append(mock.calls.Telemetry, callInfo) + mock.lockTelemetry.Unlock() + return mock.TelemetryFunc() +} + +// TelemetryCalls gets all the calls that were made to Telemetry. +// Check the length with: +// +// len(mockedConfig.TelemetryCalls()) +func (mock *ConfigMock) TelemetryCalls() []struct { +} { + var calls []struct { + } + mock.lockTelemetry.RLock() + calls = mock.calls.Telemetry + mock.lockTelemetry.RUnlock() + return calls +} + // Version calls VersionFunc. func (mock *ConfigMock) Version() o.Option[string] { if mock.VersionFunc == nil { diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 8690078c66e..9112d428372 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "time" @@ -19,6 +20,8 @@ import ( "github.com/cli/cli/v2/internal/build" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/config/migration" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/internal/update" "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmd/root" @@ -48,16 +51,57 @@ func Main() exitCode { cmdFactory := factory.New(buildVersion, string(agents.Detect())) stderr := cmdFactory.IOStreams.ErrOut - ctx := context.Background() + cfg, err := cmdFactory.Config() + if err != nil { + fmt.Fprintf(stderr, "failed to load config: %s\n", err) + return exitError + } - if cfg, err := cmdFactory.Config(); err == nil { - var m migration.MultiAccount - if err := cfg.Migrate(m); err != nil { - fmt.Fprintln(stderr, err) + additionalCommonDimensions := ghtelemetry.Dimensions{ + "version": strings.TrimPrefix(buildVersion, "v"), + "is_tty": strconv.FormatBool(cmdFactory.IOStreams.IsStdoutTTY()), + "agent": string(agents.Detect()), + } + + var telemetryService ghtelemetry.Service + if os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" { + telemetryService = &telemetry.NoOpService{} + } else { + + telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) + switch telemetryState { + case telemetry.Disabled: + telemetryService = &telemetry.NoOpService{} + case telemetry.Logged: + telemetryService = telemetry.NewService( + telemetry.LogFlusher(cmdFactory.IOStreams.ErrOut, cmdFactory.IOStreams.ColorEnabled()), + telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), + ) + case telemetry.Enabled: + sampleRate := 1 + if v, err := strconv.Atoi(os.Getenv("GH_TELEMETRY_SAMPLE_RATE")); err == nil && v >= 0 && v <= 100 { + sampleRate = v + } + additionalCommonDimensions["sample_rate"] = strconv.Itoa(sampleRate) + telemetryService = telemetry.NewService( + telemetry.GitHubFlusher(cmdFactory.Executable()), + telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), + telemetry.WithSampleRate(sampleRate), + ) + default: + fmt.Fprintf(stderr, "invalid telemetry configuration: %q\n", cfg.Telemetry().Value) return exitError } } + defer telemetryService.Flush() + + var m migration.MultiAccount + if err := cfg.Migrate(m); err != nil { + fmt.Fprintln(stderr, err) + return exitError + } + ctx := context.Background() updateCtx, updateCancel := context.WithCancel(ctx) defer updateCancel() updateMessageChan := make(chan *update.ReleaseInfo) @@ -90,7 +134,7 @@ func Main() exitCode { cobra.MousetrapHelpText = "" } - rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate) + rootCmd, err := root.NewCmdRoot(cmdFactory, telemetryService, buildVersion, buildDate) if err != nil { fmt.Fprintf(stderr, "failed to create root command: %s\n", err) return exitError diff --git a/internal/telemetry/detach_unix.go b/internal/telemetry/detach_unix.go new file mode 100644 index 00000000000..f2f6011bcd9 --- /dev/null +++ b/internal/telemetry/detach_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package telemetry + +import "syscall" + +// detachAttrs returns SysProcAttr configured to place the child in its own +// process group so that terminal signals delivered to the parent's group +// (SIGINT, SIGHUP) are not forwarded to the child. +func detachAttrs() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setpgid: true} +} diff --git a/internal/telemetry/detach_windows.go b/internal/telemetry/detach_windows.go new file mode 100644 index 00000000000..eb610163b5d --- /dev/null +++ b/internal/telemetry/detach_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package telemetry + +import ( + "syscall" + + "golang.org/x/sys/windows" +) + +// detachAttrs returns SysProcAttr configured to place the child in its own +// process group so that console signals (Ctrl+C) delivered to the parent's +// group are not forwarded to the child. +func detachAttrs() *syscall.SysProcAttr { + return &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS} +} diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go new file mode 100644 index 00000000000..ee38262d9d5 --- /dev/null +++ b/internal/telemetry/fake.go @@ -0,0 +1,13 @@ +package telemetry + +import "github.com/cli/cli/v2/internal/gh/ghtelemetry" + +type EventRecorderSpy struct { + Events []ghtelemetry.Event +} + +func (r *EventRecorderSpy) Record(event ghtelemetry.Event) { + r.Events = append(r.Events, event) +} + +func (r *EventRecorderSpy) Flush() {} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 00000000000..f8698706ac3 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,384 @@ +// Package telemetry provides best-effort usage telemetry for gh commands. +package telemetry + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "time" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/pkg/jsoncolor" + "github.com/google/uuid" + "github.com/mgutz/ansi" +) + +const deviceIDFileName = "device-id" + +// stateDirFunc returns the state directory path. Can be replaced in tests. +var stateDirFunc = config.StateDir + +// deviceIDFunc returns a per-user device identifier stored in the state directory. +// It generates and persists a UUID on first call. Can be replaced in tests. +var deviceIDFunc = getOrCreateDeviceID + +func getOrCreateDeviceID() (string, error) { + stateDir := stateDirFunc() + idPath := filepath.Join(stateDir, deviceIDFileName) + + data, err := os.ReadFile(idPath) + if err == nil { + return strings.TrimSpace(string(data)), nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + id := uuid.New().String() + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return "", err + } + + // Write the ID to a temp file in the same directory, then hard-link it + // to the target path. os.Link fails atomically if the target already + // exists, so exactly one concurrent caller wins. Losers read the + // winner's ID. The temp file is always cleaned up. + tmpFile, err := os.CreateTemp(stateDir, deviceIDFileName+".tmp.*") + if err != nil { + return "", err + } + tmpPath := tmpFile.Name() + + if _, err := tmpFile.WriteString(id); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return "", err + } + if err := tmpFile.Close(); err != nil { + os.Remove(tmpPath) + return "", err + } + + linkErr := os.Link(tmpPath, idPath) + os.Remove(tmpPath) + + if linkErr != nil { + // Another caller won — read their ID. + data, readErr := os.ReadFile(idPath) + if readErr != nil { + return "", linkErr + } + return strings.TrimSpace(string(data)), nil + } + + return id, nil +} + +var falseyValues = []string{"", "0", "false", "no", "disabled", "off"} + +// lookupEnvFunc wraps os.LookupEnv. Can be replaced in tests. +var lookupEnvFunc = os.LookupEnv + +type TelemetryState string + +const ( + Enabled TelemetryState = "enabled" + Disabled TelemetryState = "disabled" + Logged TelemetryState = "log" +) + +// ParseTelemetryState determines the telemetry state based on environment variables and configuration values. +// The GH_TELEMETRY environment variable takes precedence, followed by DO_NOT_TRACK, then the configuration value. +// Recognized values for GH_TELEMETRY and config are "enabled", "disabled", "log", or any falsey value (e.g. "0", "false", "no") to disable telemetry. +func ParseTelemetryState(configValue string) TelemetryState { + // GH_TELEMETRY env var takes highest precedence + if envVal, ok := lookupEnvFunc("GH_TELEMETRY"); ok { + envVal = strings.TrimSpace(strings.ToLower(envVal)) + + // If falsey, telemetry is disabled. + if slices.Contains(falseyValues, envVal) { + return Disabled + } + + // If logged, telemetry is logged instead of sent. + if envVal == "log" { + return Logged + } + + // Any other value (including "enabled") is treated as enabled. + return Enabled + } + + // DO_NOT_TRACK takes precedence over config + if envVal, ok := lookupEnvFunc("DO_NOT_TRACK"); ok { + envVal = strings.TrimSpace(strings.ToLower(envVal)) + if envVal == "1" || envVal == "true" { + return Disabled + } + } + + // Then check the config values with the same rules. + configValue = strings.TrimSpace(strings.ToLower(configValue)) + + if slices.Contains(falseyValues, configValue) { + return Disabled + } + + if configValue == "log" { + return Logged + } + + return Enabled +} + +type telemetryServiceOpts struct { + additionalDimensions ghtelemetry.Dimensions + sampleRate int +} + +type telemetryServiceOption func(*telemetryServiceOpts) + +// WithAdditionalCommonDimensions allows setting additional common dimensions that will be included with every telemetry event recorded by the service. +func WithAdditionalCommonDimensions(dimensions ghtelemetry.Dimensions) telemetryServiceOption { + return func(s *telemetryServiceOpts) { + maps.Copy(s.additionalDimensions, dimensions) + } +} + +// WithSampleRate allows setting a sample rate (0-100) for telemetry events. Events recorded with the Unsampled option will be sent regardless of the sample rate. +// Sampling is based on invocation ID, so an entire invocation will be included or excluded as a whole. This ensures that related events are not split between sampled and unsampled, +// which could lead to incomplete data and incorrect assumptions. +func WithSampleRate(rate int) telemetryServiceOption { + return func(s *telemetryServiceOpts) { + s.sampleRate = rate + } +} + +// LogFlusher returns a flush function that writes telemetry payloads to the provided log writer. This is used for the "log" telemetry mode, which is intended for debugging and development. +var LogFlusher = func(log io.Writer, colorEnabled bool) func(payload SendTelemetryPayload) { + return func(payload SendTelemetryPayload) { + payloadBytes, err := json.Marshal(payload) + if err != nil { + return + } + + header := "Telemetry payload:" + if colorEnabled { + header = ansi.Color(header, "cyan+b") + } + fmt.Fprintf(log, "%s\n", header) + + if colorEnabled { + _ = jsoncolor.Write(log, bytes.NewReader(payloadBytes), " ") + } else { + var indented bytes.Buffer + _ = json.Indent(&indented, payloadBytes, "", " ") + fmt.Fprintln(log, indented.String()) + } + } +} + +// GitHubFlusher returns a flush function that sends telemetry payloads to a child `gh send-telemetry` process. This is used for the "enabled" telemetry mode. +var GitHubFlusher = func(executable string) func(payload SendTelemetryPayload) { + return func(payload SendTelemetryPayload) { + SpawnSendTelemetry(executable, payload) + } +} + +// NewService creates a new telemetry service with the provided flush function and options. +func NewService(flusher func(SendTelemetryPayload), opts ...telemetryServiceOption) ghtelemetry.Service { + telemetryServiceOpts := telemetryServiceOpts{ + additionalDimensions: make(ghtelemetry.Dimensions), + } + for _, opt := range opts { + opt(&telemetryServiceOpts) + } + + deviceID, err := deviceIDFunc() + if err != nil { + deviceID = "" + } + + invocationID := uuid.NewString() + + var commonDimensions = ghtelemetry.Dimensions{ + "device_id": deviceID, + "invocation_id": invocationID, + "os": runtime.GOOS, + "architecture": runtime.GOARCH, + } + maps.Copy(commonDimensions, telemetryServiceOpts.additionalDimensions) + + hash := uuid.NewSHA1(uuid.Nil, []byte(invocationID)) + sampleBucket := hash[0] % 100 + + s := &service{ + flush: flusher, + commonDimensions: commonDimensions, + sampleRate: telemetryServiceOpts.sampleRate, + sampleBucket: sampleBucket, + } + + return s +} + +type recordedEvent struct { + event ghtelemetry.Event + recordedAt time.Time +} + +type service struct { + mu sync.RWMutex + flush func(payload SendTelemetryPayload) + previouslyCalled bool + + commonDimensions ghtelemetry.Dimensions + sampleRate int + sampleBucket byte + + events []recordedEvent +} + +func (s *service) Record(event ghtelemetry.Event) { + s.mu.Lock() + defer s.mu.Unlock() + + s.events = append(s.events, recordedEvent{event: event, recordedAt: time.Now()}) +} + +func (s *service) SetSampleRate(rate int) { + s.mu.Lock() + defer s.mu.Unlock() + + s.sampleRate = rate +} + +func (s *service) Flush() { + // This shouldn't really be required since flush should only be called once, but just in case... + s.mu.Lock() + defer s.mu.Unlock() + + if s.previouslyCalled { + return + } + s.previouslyCalled = true + + if len(s.events) == 0 { + return + } + + if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate { + return + } + + payload := SendTelemetryPayload{ + Events: make([]PayloadEvent, len(s.events)), + } + + for i, recorded := range s.events { + dimensions := map[string]string{ + "timestamp": recorded.recordedAt.UTC().Format("2006-01-02T15:04:05.000Z"), + } + maps.Copy(dimensions, s.commonDimensions) + maps.Copy(dimensions, recorded.event.Dimensions) + + payload.Events[i] = PayloadEvent{ + Type: recorded.event.Type, + Dimensions: dimensions, + Measures: recorded.event.Measures, + } + } + + s.flush(payload) +} + +// maxPayloadSize is a safety limit for the telemetry payload written to the +// child process stdin pipe. This bounds the data transferred to a reasonable +// size and avoids blocking on pipe buffer capacity (typically 16-64 KB). +const maxPayloadSize = 16 * 1024 + +// PayloadEvent represents a single telemetry event in the wire format. +type PayloadEvent struct { + Type string `json:"type"` + Dimensions map[string]string `json:"dimensions,omitempty"` + Measures map[string]int64 `json:"measures,omitempty"` +} + +type SendTelemetryPayload struct { + Events []PayloadEvent `json:"events"` +} + +// SpawnSendTelemetry spawns a detached subprocess to send telemetry. +// The payload is written to the child's stdin via a pipe so that it is not +// visible to other users through process argument inspection (e.g. ps aux). +// The parent writes the full payload and closes the pipe before returning, +// so no long-lived pipe is needed and the parent can exit immediately. +// +// Note: the payload is bounded by maxPayloadSize (16 KB). On macOS the +// default pipe buffer is also 16 KB, so in theory a write could block +// briefly if the child hasn't started reading yet. In practice the child +// is already running after cmd.Start(), so this is unlikely. +// +// All errors are silently ignored since telemetry is best-effort. +func SpawnSendTelemetry(executable string, payload SendTelemetryPayload) { + payloadBytes, err := json.Marshal(payload) + if err != nil { + return + } + + if len(payloadBytes) > maxPayloadSize { + return + } + + cmd := exec.Command(executable, "send-telemetry") + + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + + // Set the working directory to a stable directory elsewhere so that the subprocess doesn't + // hold a reference to the parent's current working directory, avoiding any weirdness around + // deleting the parent process's current working directory while the child is still running. + cmd.Dir = os.TempDir() + + // Configure the child process to be detached from the parent so that it can continue running + // after the parent exits, and so that it doesn't receive any signals sent to the parent. + cmd.SysProcAttr = detachAttrs() + + // Get the write end of the stdin pipe before starting. + stdin, err := cmd.StdinPipe() + if err != nil { + return + } + + if err := cmd.Start(); err != nil { + _ = stdin.Close() + return + } + + // Write the payload synchronously into the kernel pipe buffer, then close + // the pipe to signal EOF. The child reads the complete payload from stdin. + _, _ = stdin.Write(payloadBytes) + _ = stdin.Close() + + // Release resources associated with the child process since we will never Wait for it. + _ = cmd.Process.Release() +} + +type NoOpService struct{} + +func (s *NoOpService) Record(event ghtelemetry.Event) {} + +func (s *NoOpService) SetSampleRate(rate int) {} + +func (s *NoOpService) Flush() {} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 00000000000..0142d4d1611 --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,624 @@ +package telemetry + +import ( + "bytes" + "errors" + "maps" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func stubStateDir(dir string) func() { + orig := stateDirFunc + stateDirFunc = func() string { return dir } + return func() { stateDirFunc = orig } +} + +func stubDeviceID(id string) func() { + orig := deviceIDFunc + deviceIDFunc = func() (string, error) { return id, nil } + return func() { deviceIDFunc = orig } +} + +func stubDeviceIDError(err error) func() { + orig := deviceIDFunc + deviceIDFunc = func() (string, error) { return "", err } + return func() { deviceIDFunc = orig } +} + +func stubLookupEnv(fn func(string) (string, bool)) func() { + orig := lookupEnvFunc + lookupEnvFunc = fn + return func() { lookupEnvFunc = orig } +} + +// newService is a test helper that constructs the internal service struct +// directly, bypassing the config/env parsing of NewService but still +// resolving common dimensions like device_id and invocation_id. +func newService(flusher func(SendTelemetryPayload), additionalDimensions ghtelemetry.Dimensions) *service { + deviceID, err := deviceIDFunc() + if err != nil { + deviceID = "" + } + + commonDimensions := ghtelemetry.Dimensions{ + "device_id": deviceID, + "invocation_id": uuid.NewString(), + } + maps.Copy(commonDimensions, additionalDimensions) + + return &service{ + flush: flusher, + commonDimensions: commonDimensions, + } +} + +func TestGetOrCreateDeviceID(t *testing.T) { + t.Run("creates new ID on first call", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + id, err := getOrCreateDeviceID() + require.NoError(t, err) + require.NotEmpty(t, id) + + data, err := os.ReadFile(filepath.Join(tmpDir, deviceIDFileName)) + require.NoError(t, err) + assert.Equal(t, id, string(data)) + }) + + t.Run("returns same ID on subsequent calls", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + id1, err := getOrCreateDeviceID() + require.NoError(t, err) + + id2, err := getOrCreateDeviceID() + require.NoError(t, err) + + assert.Equal(t, id1, id2) + }) + + t.Run("trims whitespace from stored ID", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + err := os.WriteFile(filepath.Join(tmpDir, deviceIDFileName), []byte(" some-device-id\n"), 0o600) + require.NoError(t, err) + + id, err := getOrCreateDeviceID() + require.NoError(t, err) + assert.Equal(t, "some-device-id", id) + }) + + t.Run("returns error for non-ErrNotExist read failures", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + // Create device-id as a directory so ReadFile fails with a non-ErrNotExist error. + err := os.Mkdir(filepath.Join(tmpDir, deviceIDFileName), 0o755) + require.NoError(t, err) + + _, err = getOrCreateDeviceID() + require.Error(t, err) + assert.False(t, errors.Is(err, os.ErrNotExist)) + }) + + t.Run("creates state directory if missing", func(t *testing.T) { + tmpDir := t.TempDir() + nestedDir := filepath.Join(tmpDir, "nested", "state") + t.Cleanup(stubStateDir(nestedDir)) + + id, err := getOrCreateDeviceID() + require.NoError(t, err) + require.NotEmpty(t, id) + + data, err := os.ReadFile(filepath.Join(nestedDir, deviceIDFileName)) + require.NoError(t, err) + assert.Equal(t, id, string(data)) + }) + + t.Run("concurrent callers converge on the same ID", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + const goroutines = 10 + ids := make([]string, goroutines) + errs := make([]error, goroutines) + var wg sync.WaitGroup + wg.Add(goroutines) + for i := range goroutines { + go func() { + defer wg.Done() + ids[i], errs[i] = getOrCreateDeviceID() + }() + } + wg.Wait() + + for i := range goroutines { + require.NoError(t, errs[i]) + } + for i := 1; i < goroutines; i++ { + assert.Equal(t, ids[0], ids[i], "goroutine %d returned a different ID", i) + } + }) +} + +func TestParseTelemetryState(t *testing.T) { + envSet := func(val string) func(string) (string, bool) { + return func(string) (string, bool) { return val, true } + } + envUnset := func(string) (string, bool) { return "", false } + + // envMap allows setting multiple environment variables for testing DO_NOT_TRACK + GH_TELEMETRY interactions. + envMap := func(m map[string]string) func(string) (string, bool) { + return func(key string) (string, bool) { + val, ok := m[key] + return val, ok + } + } + + tests := []struct { + name string + lookupEnv func(string) (string, bool) + configValue string + want TelemetryState + }{ + { + name: "env unset, config empty string disables", + lookupEnv: envUnset, + configValue: "", + want: Disabled, + }, + { + name: "env unset, config enabled", + lookupEnv: envUnset, + configValue: "enabled", + want: Enabled, + }, + { + name: "env unset, config disabled", + lookupEnv: envUnset, + configValue: "disabled", + want: Disabled, + }, + { + name: "env unset, config log", + lookupEnv: envUnset, + configValue: "log", + want: Logged, + }, + { + name: "env unset, config false", + lookupEnv: envUnset, + configValue: "false", + want: Disabled, + }, + { + name: "env unset, config any truthy value", + lookupEnv: envUnset, + configValue: "anything", + want: Enabled, + }, + { + name: "env enabled takes precedence over config disabled", + lookupEnv: envSet("enabled"), + configValue: "disabled", + want: Enabled, + }, + { + name: "env disabled takes precedence over config enabled", + lookupEnv: envSet("disabled"), + configValue: "enabled", + want: Disabled, + }, + { + name: "env log takes precedence over config enabled", + lookupEnv: envSet("log"), + configValue: "enabled", + want: Logged, + }, + { + name: "env false disables", + lookupEnv: envSet("false"), + configValue: "enabled", + want: Disabled, + }, + { + name: "env empty string disables", + lookupEnv: envSet(""), + configValue: "enabled", + want: Disabled, + }, + { + name: "env any truthy value enables", + lookupEnv: envSet("yes"), + configValue: "disabled", + want: Enabled, + }, + { + name: "env FALSE (uppercase) disables", + lookupEnv: envSet("FALSE"), + configValue: "enabled", + want: Disabled, + }, + { + name: "env LOG (uppercase) logs", + lookupEnv: envSet("LOG"), + configValue: "enabled", + want: Logged, + }, + { + name: "env value with whitespace is trimmed", + lookupEnv: envSet(" false "), + configValue: "enabled", + want: Disabled, + }, + { + name: "DO_NOT_TRACK=1 disables telemetry", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "1"}), + configValue: "enabled", + want: Disabled, + }, + { + name: "DO_NOT_TRACK=true disables telemetry", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "true"}), + configValue: "enabled", + want: Disabled, + }, + { + name: "DO_NOT_TRACK=TRUE disables telemetry (case insensitive)", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "TRUE"}), + configValue: "enabled", + want: Disabled, + }, + { + name: "DO_NOT_TRACK=0 does not disable telemetry", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "0"}), + configValue: "enabled", + want: Enabled, + }, + { + name: "DO_NOT_TRACK with whitespace is trimmed", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": " 1 "}), + configValue: "enabled", + want: Disabled, + }, + { + name: "GH_TELEMETRY takes precedence over DO_NOT_TRACK", + lookupEnv: envMap(map[string]string{"GH_TELEMETRY": "enabled", "DO_NOT_TRACK": "1"}), + configValue: "", + want: Enabled, + }, + { + name: "DO_NOT_TRACK takes precedence over config", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "1"}), + configValue: "log", + want: Disabled, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(stubLookupEnv(tt.lookupEnv)) + got := ParseTelemetryState(tt.configValue) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNewServiceLogModeFlushesToWriter(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var buf bytes.Buffer + svc := NewService(LogFlusher(&buf, false)) + + svc.Record(ghtelemetry.Event{ + Type: "test_event", + Dimensions: map[string]string{"key": "value"}, + }) + svc.Flush() + + output := buf.String() + assert.Contains(t, output, "Telemetry payload:") + assert.Contains(t, output, "test_event") + assert.Contains(t, output, `"key"`) + assert.Contains(t, output, `"value"`) +} + +func TestNewServiceLogModeWithColorLogsToWriter(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var buf bytes.Buffer + svc := NewService(LogFlusher(&buf, true)) + + svc.Record(ghtelemetry.Event{Type: "color_event"}) + svc.Flush() + + output := buf.String() + assert.Contains(t, output, "color_event") + // Verify ANSI color codes are present in the output + assert.Contains(t, output, "\033[", "expected ANSI escape sequences when color is enabled") +} + +func TestServiceDeviceIDFallback(t *testing.T) { + t.Cleanup(stubDeviceIDError(errors.New("no device id"))) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + assert.Equal(t, "", captured.Events[0].Dimensions["device_id"]) +} + +func TestServiceFlush(t *testing.T) { + t.Run("does nothing when no events recorded", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc.Flush() + + assert.False(t, called, "flusher should not be called with no events") + }) + + t.Run("flushes events with merged dimensions", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{"version": "2.45.0"}) + + svc.Record(ghtelemetry.Event{ + Type: "command_invocation", + Dimensions: map[string]string{"command": "gh pr list"}, + Measures: map[string]int64{"duration_ms": 150}, + }) + svc.Flush() + + require.Len(t, captured.Events, 1) + event := captured.Events[0] + assert.Equal(t, "command_invocation", event.Type) + assert.Equal(t, "gh pr list", event.Dimensions["command"]) + assert.Equal(t, "2.45.0", event.Dimensions["version"]) + assert.Equal(t, "test-device", event.Dimensions["device_id"]) + assert.NotEmpty(t, event.Dimensions["timestamp"]) + assert.NotEmpty(t, event.Dimensions["invocation_id"]) + assert.Equal(t, int64(150), event.Measures["duration_ms"]) + }) + + t.Run("flushes multiple events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + + svc.Record(ghtelemetry.Event{Type: "event1"}) + svc.Record(ghtelemetry.Event{Type: "event2"}) + svc.Flush() + + require.Len(t, captured.Events, 2) + assert.Equal(t, "event1", captured.Events[0].Type) + assert.Equal(t, "event2", captured.Events[1].Type) + }) + + t.Run("is idempotent", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + callCount := 0 + svc := newService(func(SendTelemetryPayload) { callCount++ }, nil) + svc.Record(ghtelemetry.Event{Type: "test"}) + + svc.Flush() + svc.Flush() + svc.Flush() + + assert.Equal(t, 1, callCount, "flusher should only be called once") + }) + + t.Run("event dimensions override common dimensions", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{"shared": "common"}) + + svc.Record(ghtelemetry.Event{ + Type: "test", + Dimensions: map[string]string{"shared": "event-level"}, + }) + svc.Flush() + + require.Len(t, captured.Events, 1) + // Event dimensions are copied last via maps.Copy, so they override common + assert.Equal(t, "event-level", captured.Events[0].Dimensions["shared"]) + }) + + t.Run("timestamps reflect record time not flush time", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + + svc.Record(ghtelemetry.Event{Type: "early"}) + time.Sleep(50 * time.Millisecond) + svc.Record(ghtelemetry.Event{Type: "late"}) + svc.Flush() + + require.Len(t, captured.Events, 2) + ts1 := captured.Events[0].Dimensions["timestamp"] + ts2 := captured.Events[1].Dimensions["timestamp"] + require.NotEmpty(t, ts1) + require.NotEmpty(t, ts2) + + t1, err := time.Parse("2006-01-02T15:04:05.000Z", ts1) + require.NoError(t, err) + t2, err := time.Parse("2006-01-02T15:04:05.000Z", ts2) + require.NoError(t, err) + + assert.True(t, t2.After(t1), "second event timestamp %s should be after first %s", ts2, ts1) + }) +} + +func TestServiceSampling(t *testing.T) { + t.Run("sampleRate 0 sends all events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + svc.sampleRate = 0 + svc.sampleBucket = 99 + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + }) + + t.Run("sampleRate 100 sends all events regardless of bucket", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + svc.sampleRate = 100 + svc.sampleBucket = 99 + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + }) + + t.Run("bucket below sampleRate sends events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + svc.sampleRate = 50 + svc.sampleBucket = 49 // below rate, should be included + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + }) + + t.Run("bucket at sampleRate drops events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc.sampleRate = 50 + svc.sampleBucket = 50 // at rate boundary, should be excluded + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + assert.False(t, called, "flusher should not be called when bucket >= sampleRate") + }) + + t.Run("bucket above sampleRate drops events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc.sampleRate = 1 + svc.sampleBucket = 50 + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + assert.False(t, called, "flusher should not be called when bucket >= sampleRate") + }) + + t.Run("SetSampleRate changes flush behavior", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc.sampleBucket = 50 + + // Initially rate=0, which sends everything + svc.SetSampleRate(10) // Now bucket=50 >= rate=10, should drop + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + assert.False(t, called, "flusher should not be called after SetSampleRate reduced the rate") + }) + + t.Run("WithSampleRate option sets rate on construction", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := NewService(func(SendTelemetryPayload) { called = true }, WithSampleRate(1)) + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + // We can't control the bucket from NewService, so we just verify + // the service was created without error and Flush doesn't panic. + // The actual sampling behavior is tested via direct struct manipulation above. + _ = called + }) +} + +func TestWithAdditionalCommonDimensions(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := NewService( + func(p SendTelemetryPayload) { captured = p }, + WithAdditionalCommonDimensions(ghtelemetry.Dimensions{ + "version": "2.45.0", + "agent": "none", + }), + ) + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + assert.Equal(t, "2.45.0", captured.Events[0].Dimensions["version"]) + assert.Equal(t, "none", captured.Events[0].Dimensions["agent"]) + // Standard common dimensions should also be present + assert.Equal(t, "test-device", captured.Events[0].Dimensions["device_id"]) + assert.NotEmpty(t, captured.Events[0].Dimensions["invocation_id"]) + assert.NotEmpty(t, captured.Events[0].Dimensions["os"]) + assert.NotEmpty(t, captured.Events[0].Dimensions["architecture"]) +} + +func TestNoOpService(t *testing.T) { + svc := &NoOpService{} + // All methods should be safe to call without panicking + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.SetSampleRate(50) + svc.Flush() +} + +func TestSpawnSendTelemetryRejectsOversizedPayload(t *testing.T) { + // Build a payload larger than maxPayloadSize (16KB) + largeDimensions := map[string]string{ + "data": strings.Repeat("x", maxPayloadSize), + } + payload := SendTelemetryPayload{ + Events: []PayloadEvent{ + {Type: "test", Dimensions: largeDimensions}, + }, + } + + // This should not panic or spawn a process - it silently returns. + // We can't easily assert the subprocess wasn't started, but we verify + // the function doesn't crash. + SpawnSendTelemetry("/nonexistent/binary", payload) +} diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go index 70e01653f14..e8154f42495 100644 --- a/pkg/cmd/auth/auth.go +++ b/pkg/cmd/auth/auth.go @@ -31,5 +31,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(authTokenCmd.NewCmdToken(f, nil)) cmd.AddCommand(authSwitchCmd.NewCmdSwitch(f, nil)) + cmdutil.DisableTelemetryForSubcommands(cmd) + return cmd } diff --git a/pkg/cmd/completion/completion.go b/pkg/cmd/completion/completion.go index b34bf7abfb5..b703fa24952 100644 --- a/pkg/cmd/completion/completion.go +++ b/pkg/cmd/completion/completion.go @@ -93,6 +93,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { cmdutil.DisableAuthCheck(cmd) cmdutil.StringEnumFlag(cmd, &shellType, "shell", "s", "", []string{"bash", "zsh", "fish", "powershell"}, "Shell type") + cmdutil.DisableTelemetry(cmd) return cmd } diff --git a/pkg/cmd/config/list/list_test.go b/pkg/cmd/config/list/list_test.go index 27260e85783..61d3db35981 100644 --- a/pkg/cmd/config/list/list_test.go +++ b/pkg/cmd/config/list/list_test.go @@ -104,6 +104,7 @@ func Test_listRun(t *testing.T) { accessible_colors=disabled accessible_prompter=disabled spinner=enabled + telemetry=enabled `), }, } diff --git a/pkg/cmd/root/extension.go b/pkg/cmd/root/extension.go index 7e2d7aca75f..45a60332b8d 100644 --- a/pkg/cmd/root/extension.go +++ b/pkg/cmd/root/extension.go @@ -8,6 +8,7 @@ import ( "time" "github.com/cli/cli/v2/internal/update" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/utils" @@ -26,7 +27,7 @@ func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ex checkExtensionReleaseInfo = checkForExtensionUpdate } - return &cobra.Command{ + cmd := &cobra.Command{ Use: ext.Name(), Short: fmt.Sprintf("Extension %s", ext.Name()), // PreRun handles looking up whether extension has a latest version only when the command is ran. @@ -73,12 +74,14 @@ func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ex // This is being handled in non-blocking default as there is no context to cancel like in gh update checks. } }, - GroupID: "extension", - Annotations: map[string]string{ - "skipAuthCheck": "true", - }, + GroupID: "extension", DisableFlagParsing: true, } + + cmdutil.DisableAuthCheck(cmd) + cmdutil.DisableTelemetry(cmd) + + return cmd } func checkForExtensionUpdate(em extensions.ExtensionManager, ext extensions.Extension) (*update.ReleaseInfo, error) { diff --git a/pkg/cmd/root/extension_registration_test.go b/pkg/cmd/root/extension_registration_test.go index 90b836e4a47..828f51ca066 100644 --- a/pkg/cmd/root/extension_registration_test.go +++ b/pkg/cmd/root/extension_registration_test.go @@ -6,6 +6,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" @@ -74,7 +75,7 @@ func TestNewCmdRoot_ExtensionRegistration(t *testing.T) { ExtensionManager: em, } - cmd, err := NewCmdRoot(f, "", "") + cmd, err := NewCmdRoot(f, &telemetry.NoOpService{}, "", "") require.NoError(t, err) // Verify skipped extensions (should find core command registered, not extension) diff --git a/pkg/cmd/root/help_test.go b/pkg/cmd/root/help_test.go index 40f333159de..e7f04375845 100644 --- a/pkg/cmd/root/help_test.go +++ b/pkg/cmd/root/help_test.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" @@ -74,7 +75,7 @@ func TestKramdownCompatibleDocs(t *testing.T) { }, } - cmd, err := NewCmdRoot(f, "N/A", "") + cmd, err := NewCmdRoot(f, &telemetry.NoOpService{}, "N/A", "") require.NoError(t, err) var walk func(*cobra.Command) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index a375d9e2050..fbacef356cc 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -117,6 +117,12 @@ var HelpTopics = []helpTopic{ %[1]sGH_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are more compatible with speech synthesis and braille screen readers. + %[1]sGH_TELEMETRY%[1]s: set to %[1]slog%[1]s to print telemetry data to standard error instead of sending it. + Set to %[1]sfalse%[1]s or %[1]s0%[1]s to disable telemetry that would have been printed when set to %[1]slog%[1]s. + + %[1]sDO_NOT_TRACK%[1]s: set to %[1]strue%[1]s or %[1]s1%[1]s to disable telemetry that would have been printed + when %[1]sGH_TELEMETRY%[1]s is set to %[1]slog%[1]s. %[1]sGH_TELEMETRY%[1]s takes precedence if both are set. + %[1]sGH_SPINNER_DISABLED%[1]s: set to a truthy value to replace the spinner animation with a textual progress indicator. `, "`"), diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 37684b40c8c..7df9d2986ea 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" accessibilityCmd "github.com/cli/cli/v2/pkg/cmd/accessibility" actionsCmd "github.com/cli/cli/v2/pkg/cmd/actions" agentTaskCmd "github.com/cli/cli/v2/pkg/cmd/agent-task" @@ -38,6 +39,7 @@ import ( runCmd "github.com/cli/cli/v2/pkg/cmd/run" searchCmd "github.com/cli/cli/v2/pkg/cmd/search" secretCmd "github.com/cli/cli/v2/pkg/cmd/secret" + sendTelemetryCmd "github.com/cli/cli/v2/pkg/cmd/send-telemetry" skillsCmd "github.com/cli/cli/v2/pkg/cmd/skills" sshKeyCmd "github.com/cli/cli/v2/pkg/cmd/ssh-key" statusCmd "github.com/cli/cli/v2/pkg/cmd/status" @@ -58,7 +60,7 @@ func (ae *AuthError) Error() string { return ae.err.Error() } -func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, error) { +func NewCmdRoot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, version, buildDate string) (*cobra.Command, error) { io := f.IOStreams cfg, err := f.Config() if err != nil { @@ -88,6 +90,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, } return &AuthError{} } + return nil }, } @@ -153,6 +156,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(statusCmd.NewCmdStatus(f, nil)) cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil)) cmd.AddCommand(licensesCmd.NewCmdLicenses(f)) + cmd.AddCommand(sendTelemetryCmd.NewCmdSendTelemetry(f)) // below here at the commands that require the "intelligent" BaseRepo resolver repoResolvingCmdFactory := *f @@ -244,6 +248,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, } cmdutil.DisableAuthCheck(cmd) + cmdutil.RecordTelemetryForSubcommands(cmd, telemetry) // The reference command produces paged output that displays information on every other command. // Therefore, we explicitly set the Long text and HelpFunc here after all other commands are registered. diff --git a/pkg/cmd/send-telemetry/send_telemetry.go b/pkg/cmd/send-telemetry/send_telemetry.go new file mode 100644 index 00000000000..fce6dff8391 --- /dev/null +++ b/pkg/cmd/send-telemetry/send_telemetry.go @@ -0,0 +1,135 @@ +package sendtelemetry + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "time" + + "github.com/cli/cli/v2/internal/barista/observability" + "github.com/cli/cli/v2/internal/build" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +const defaultTelemetryEndpointURL = "https://cafe.github.com" + +type SendTelemetryOptions struct { + TelemetryEndpointURL string + PayloadJSON string + HTTPUnixSocket string +} + +func NewCmdSendTelemetry(f *cmdutil.Factory) *cobra.Command { + return newCmdSendTelemetry(f, nil) +} + +func newCmdSendTelemetry(f *cmdutil.Factory, runF func(*SendTelemetryOptions) error) *cobra.Command { + cmd := &cobra.Command{ + Use: "send-telemetry", + Short: "Send telemetry event to GitHub", + Hidden: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + payloadJSON, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return fmt.Errorf("reading payload from stdin: %w", err) + } + if len(payloadJSON) == 0 { + return fmt.Errorf("no payload provided on stdin") + } + + opts := &SendTelemetryOptions{ + TelemetryEndpointURL: cmp.Or(os.Getenv("GH_TELEMETRY_ENDPOINT_URL"), defaultTelemetryEndpointURL), + PayloadJSON: string(payloadJSON), + // This is a best effort to use a Unix Socket if configured. In most cases, if there is one configured + // it will be at the global level. However, since the telemetry service is not related to a specific host, we can't + // know that the socket we choose will work. + HTTPUnixSocket: cfg.HTTPUnixSocket("").Value, + } + + if runF != nil { + return runF(opts) + } + + return runSendTelemetry(cmd.Context(), opts) + }, + } + + cmdutil.DisableAuthCheck(cmd) + cmdutil.DisableTelemetry(cmd) + + return cmd +} + +func runSendTelemetry(ctx context.Context, opts *SendTelemetryOptions) error { + httpClient := &http.Client{ + Timeout: 2 * time.Second, + Transport: &userAgentTransport{ + base: handleUnixDomainSocket(opts.HTTPUnixSocket), + userAgent: fmt.Sprintf("GitHub CLI %s", build.Version), + }, + } + + client := observability.NewTelemetryAPIProtobufClient(opts.TelemetryEndpointURL, httpClient) + + var payload telemetry.SendTelemetryPayload + if err := json.Unmarshal([]byte(opts.PayloadJSON), &payload); err != nil { + return fmt.Errorf("parsing payload JSON: %w", err) + } + + if len(payload.Events) == 0 { + return nil + } + + events := make([]*observability.TelemetryEvent, len(payload.Events)) + for i, event := range payload.Events { + events[i] = &observability.TelemetryEvent{ + App: "github-cli", + EventType: event.Type, + Dimensions: event.Dimensions, + Measures: event.Measures, + } + } + + _, err := client.RecordEvents(ctx, &observability.RecordEventsRequest{ + Events: events, + }) + return err +} + +type userAgentTransport struct { + base http.RoundTripper + userAgent string +} + +func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("User-Agent", t.userAgent) + return t.base.RoundTrip(req) +} + +func handleUnixDomainSocket(socketPath string) http.RoundTripper { + if socketPath == "" { + return http.DefaultTransport + } + + dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) + } + + return &http.Transport{ + DialContext: dialContext, + DisableKeepAlives: true, + } +} diff --git a/pkg/cmd/send-telemetry/send_telemetry_test.go b/pkg/cmd/send-telemetry/send_telemetry_test.go new file mode 100644 index 00000000000..8ec2f83c555 --- /dev/null +++ b/pkg/cmd/send-telemetry/send_telemetry_test.go @@ -0,0 +1,226 @@ +package sendtelemetry + +import ( + "context" + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/cli/cli/v2/internal/barista/observability" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockTelemetryAPI struct { + request *observability.RecordEventsRequest + err error +} + +func (m *mockTelemetryAPI) RecordEvents(_ context.Context, req *observability.RecordEventsRequest) (*observability.RecordEventsResponse, error) { + m.request = req + return &observability.RecordEventsResponse{}, m.err +} + +func TestNewCmdSendTelemetry(t *testing.T) { + tests := []struct { + name string + stdin string + env map[string]string + wantOpts SendTelemetryOptions + wantErr string + }{ + { + name: "reads payload from stdin", + stdin: `{"events":[{"type":"usage","dimensions":{"command":"gh pr list"}}]}`, + wantOpts: SendTelemetryOptions{ + TelemetryEndpointURL: defaultTelemetryEndpointURL, + PayloadJSON: `{"events":[{"type":"usage","dimensions":{"command":"gh pr list"}}]}`, + }, + }, + { + name: "uses GH_TELEMETRY_ENDPOINT_URL env var", + stdin: `{"events":[]}`, + env: map[string]string{"GH_TELEMETRY_ENDPOINT_URL": "https://custom.endpoint"}, + wantOpts: SendTelemetryOptions{ + TelemetryEndpointURL: "https://custom.endpoint", + PayloadJSON: `{"events":[]}`, + }, + }, + { + name: "defaults endpoint when env var not set", + stdin: `{}`, + wantOpts: SendTelemetryOptions{ + TelemetryEndpointURL: defaultTelemetryEndpointURL, + PayloadJSON: `{}`, + }, + }, + { + name: "errors on empty stdin", + stdin: "", + wantErr: "no payload provided on stdin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Config: func() (gh.Config, error) { + return config.NewBlankConfig(), nil + }, + } + + var gotOpts *SendTelemetryOptions + cmd := newCmdSendTelemetry(f, func(opts *SendTelemetryOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{}) + cmd.SetIn(strings.NewReader(tt.stdin)) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err := cmd.ExecuteC() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, gotOpts) + assert.Equal(t, tt.wantOpts.TelemetryEndpointURL, gotOpts.TelemetryEndpointURL) + assert.Equal(t, tt.wantOpts.PayloadJSON, gotOpts.PayloadJSON) + }) + } +} + +func TestRunSendTelemetry(t *testing.T) { + tests := []struct { + name string + payload telemetry.SendTelemetryPayload + serverErr error + wantErr bool + assertFunc func(t *testing.T, req *observability.RecordEventsRequest) + }{ + { + name: "posts single event to endpoint", + payload: telemetry.SendTelemetryPayload{ + Events: []telemetry.PayloadEvent{ + { + Type: "command_invocation", + Dimensions: map[string]string{ + "command": "gh pr create", + "device_id": "abc123", + "os": "darwin", + }, + Measures: map[string]int64{"duration_ms": 150}, + }, + }, + }, + assertFunc: func(t *testing.T, req *observability.RecordEventsRequest) { + t.Helper() + require.Len(t, req.Events, 1) + event := req.Events[0] + assert.Equal(t, "github-cli", event.App) + assert.Equal(t, "command_invocation", event.EventType) + assert.Equal(t, "gh pr create", event.Dimensions["command"]) + assert.Equal(t, "abc123", event.Dimensions["device_id"]) + assert.Equal(t, "darwin", event.Dimensions["os"]) + }, + }, + { + name: "posts multiple events in single batch request", + payload: telemetry.SendTelemetryPayload{ + Events: []telemetry.PayloadEvent{ + {Type: "event1", Dimensions: map[string]string{"a": "1"}}, + {Type: "event2", Dimensions: map[string]string{"b": "2"}}, + }, + }, + assertFunc: func(t *testing.T, req *observability.RecordEventsRequest) { + t.Helper() + require.Len(t, req.Events, 2) + assert.Equal(t, "1", req.Events[0].Dimensions["a"]) + assert.Equal(t, "2", req.Events[1].Dimensions["b"]) + assert.Equal(t, "github-cli", req.Events[0].App) + assert.Equal(t, "event1", req.Events[0].EventType) + assert.Equal(t, "github-cli", req.Events[1].App) + assert.Equal(t, "event2", req.Events[1].EventType) + }, + }, + { + name: "empty events list produces no request", + payload: telemetry.SendTelemetryPayload{ + Events: []telemetry.PayloadEvent{}, + }, + assertFunc: func(t *testing.T, req *observability.RecordEventsRequest) { + t.Helper() + assert.Nil(t, req) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockTelemetryAPI{err: tt.serverErr} + handler := observability.NewTelemetryAPIServer(mock) + server := httptest.NewServer(handler) + defer server.Close() + + opts := &SendTelemetryOptions{ + TelemetryEndpointURL: server.URL, + PayloadJSON: mustMarshal(t, tt.payload), + } + + err := runSendTelemetry(context.Background(), opts) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + if tt.assertFunc != nil { + tt.assertFunc(t, mock.request) + } + }) + } +} + +func TestRunSendTelemetryInvalidPayload(t *testing.T) { + err := runSendTelemetry(context.Background(), &SendTelemetryOptions{ + TelemetryEndpointURL: "http://localhost:0", + PayloadJSON: "not-json", + }) + require.Error(t, err) +} + +func TestRunSendTelemetryServerError(t *testing.T) { + mock := &mockTelemetryAPI{err: assert.AnError} + handler := observability.NewTelemetryAPIServer(mock) + server := httptest.NewServer(handler) + defer server.Close() + + err := runSendTelemetry(context.Background(), &SendTelemetryOptions{ + TelemetryEndpointURL: server.URL, + PayloadJSON: `{"events":[{"type":"test","dimensions":{"a":"1"}}]}`, + }) + require.Error(t, err) +} + +func mustMarshal(t *testing.T, v any) string { + t.Helper() + data, err := json.Marshal(v) + require.NoError(t, err) + return string(data) +} diff --git a/pkg/cmd/version/version.go b/pkg/cmd/version/version.go index 11d4a0271fd..4f68fbe61be 100644 --- a/pkg/cmd/version/version.go +++ b/pkg/cmd/version/version.go @@ -13,8 +13,9 @@ func NewCmdVersion(f *cmdutil.Factory, version, buildDate string) *cobra.Command cmd := &cobra.Command{ Use: "version", Hidden: true, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { fmt.Fprint(f.IOStreams.Out, cmd.Root().Annotations["versionInfo"]) + return nil }, } diff --git a/pkg/cmdutil/telemetry.go b/pkg/cmdutil/telemetry.go new file mode 100644 index 00000000000..42169beecec --- /dev/null +++ b/pkg/cmdutil/telemetry.go @@ -0,0 +1,66 @@ +package cmdutil + +import ( + "slices" + "strings" + + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func RecordTelemetry(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) { + if isTelemetryDisabled(cmd) { + return + } + + if cmd.RunE == nil { + return + } + + currentRunE := cmd.RunE + cmd.RunE = func(cmd *cobra.Command, args []string) error { + runErr := currentRunE(cmd, args) + + var flags []string + cmd.Flags().Visit(func(f *pflag.Flag) { + flags = append(flags, f.Name) + }) + slices.Sort(flags) + + telemetry.Record(ghtelemetry.Event{ + Type: "command_invocation", + Dimensions: map[string]string{ + "command": cmd.CommandPath(), + "flags": strings.Join(flags, ","), + }, + }) + + return runErr + } +} + +func RecordTelemetryForSubcommands(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) { + for _, c := range cmd.Commands() { + RecordTelemetry(c, telemetry) + RecordTelemetryForSubcommands(c, telemetry) + } +} + +func DisableTelemetry(cmd *cobra.Command) { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations["telemetry"] = "disabled" +} + +func DisableTelemetryForSubcommands(cmd *cobra.Command) { + for _, c := range cmd.Commands() { + DisableTelemetry(c) + DisableTelemetryForSubcommands(c) + } +} + +func isTelemetryDisabled(cmd *cobra.Command) bool { + return cmd.Annotations["telemetry"] == "disabled" +} diff --git a/pkg/cmdutil/telemetry_test.go b/pkg/cmdutil/telemetry_test.go new file mode 100644 index 00000000000..bfe4c420ca0 --- /dev/null +++ b/pkg/cmdutil/telemetry_test.go @@ -0,0 +1,168 @@ +package cmdutil_test + +import ( + "fmt" + "testing" + + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRecordTelemetry(t *testing.T) { + t.Run("records command path and flags", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmd.Flags().Bool("web", false, "") + cmd.Flags().String("repo", "", "") + + parent := &cobra.Command{Use: "pr"} + root := &cobra.Command{Use: "gh"} + root.AddCommand(parent) + parent.AddCommand(cmd) + + cmdutil.RecordTelemetry(cmd, recorder) + + require.NoError(t, cmd.Flags().Set("web", "true")) + require.NoError(t, cmd.Flags().Set("repo", "cli/cli")) + require.NoError(t, cmd.RunE(cmd, nil)) + + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "command_invocation", event.Type) + assert.Equal(t, "gh pr list", event.Dimensions["command"]) + assert.Equal(t, "repo,web", event.Dimensions["flags"]) + }) + + t.Run("is a no-op when original RunE is nil", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{Use: "test"} + + cmdutil.RecordTelemetry(cmd, recorder) + + assert.Nil(t, cmd.RunE, "RunE should remain nil when it was nil before") + assert.Empty(t, recorder.Events, "no telemetry should be recorded") + }) + + t.Run("propagates error from original RunE", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + expectedErr := fmt.Errorf("something went wrong") + cmd := &cobra.Command{ + Use: "fail", + RunE: func(cmd *cobra.Command, args []string) error { return expectedErr }, + } + + cmdutil.RecordTelemetry(cmd, recorder) + + err := cmd.RunE(cmd, nil) + assert.ErrorIs(t, err, expectedErr) + // Telemetry is still recorded even on error + require.Len(t, recorder.Events, 1) + assert.Equal(t, "command_invocation", recorder.Events[0].Type) + }) + + t.Run("flags are sorted alphabetically", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmd.Flags().Bool("zebra", false, "") + cmd.Flags().Bool("alpha", false, "") + cmd.Flags().Bool("middle", false, "") + + cmdutil.RecordTelemetry(cmd, recorder) + + require.NoError(t, cmd.Flags().Set("zebra", "true")) + require.NoError(t, cmd.Flags().Set("alpha", "true")) + require.NoError(t, cmd.Flags().Set("middle", "true")) + require.NoError(t, cmd.RunE(cmd, nil)) + + require.Len(t, recorder.Events, 1) + assert.Equal(t, "alpha,middle,zebra", recorder.Events[0].Dimensions["flags"]) + }) + + t.Run("no flags set records empty flags string", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmd.Flags().Bool("unused", false, "") + + cmdutil.RecordTelemetry(cmd, recorder) + require.NoError(t, cmd.RunE(cmd, nil)) + + require.Len(t, recorder.Events, 1) + assert.Equal(t, "", recorder.Events[0].Dimensions["flags"]) + }) + + t.Run("skips commands with telemetry disabled", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{ + Use: "internal", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmdutil.DisableTelemetry(cmd) + cmdutil.RecordTelemetry(cmd, recorder) + + require.NoError(t, cmd.RunE(cmd, nil)) + assert.Empty(t, recorder.Events, "telemetry should not be recorded for disabled commands") + }) +} + +func TestRecordTelemetryForSubcommands(t *testing.T) { + t.Run("instruments nested subcommands", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + + root := &cobra.Command{Use: "gh"} + parent := &cobra.Command{Use: "pr"} + child := &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + root.AddCommand(parent) + parent.AddCommand(child) + + cmdutil.RecordTelemetryForSubcommands(root, recorder) + require.NoError(t, child.RunE(child, nil)) + + require.Len(t, recorder.Events, 1) + assert.Equal(t, "command_invocation", recorder.Events[0].Type) + assert.Equal(t, "gh pr list", recorder.Events[0].Dimensions["command"]) + }) + + t.Run("skips subcommands with nil RunE", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + + root := &cobra.Command{Use: "gh"} + child := &cobra.Command{Use: "help"} // no RunE + root.AddCommand(child) + + cmdutil.RecordTelemetryForSubcommands(root, recorder) + + assert.Nil(t, child.RunE, "nil RunE should remain nil") + }) + + t.Run("skips subcommands with telemetry disabled", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + + root := &cobra.Command{Use: "gh"} + child := &cobra.Command{ + Use: "send-telemetry", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmdutil.DisableTelemetry(child) + root.AddCommand(child) + + cmdutil.RecordTelemetryForSubcommands(root, recorder) + require.NoError(t, child.RunE(child, nil)) + + assert.Empty(t, recorder.Events, "disabled commands should not record telemetry") + }) +} From d333093efd29de0a17353b37d336ce32d889e43a Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Fri, 17 Apr 2026 10:26:03 +0100 Subject: [PATCH 02/29] remove misleading text --- pkg/cmd/skills/search/search.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 05511484eae..4e0d21e9a21 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -540,7 +540,7 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { } indices, err := opts.Prompter.MultiSelect( - "Select skills to install (press Enter to skip):", + "Select skills to install:", nil, options, ) From 3ed389d664d9820f73bd02c3d77575f43299cd62 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 15 Apr 2026 14:55:35 +0200 Subject: [PATCH 03/29] Disable telemetry for GHES --- .../no-telemetry-for-ghes-user.txtar | 8 + api/http_client.go | 21 + api/http_client_test.go | 74 +++ internal/gh/ghtelemetry/telemetry.go | 5 + internal/ghcmd/cmd.go | 174 ++++++- internal/ghcmd/cmd_test.go | 435 ++++++++++++++++++ .../ghcmd/executable_test.go | 11 +- internal/telemetry/fake.go | 2 + internal/telemetry/telemetry.go | 15 + internal/telemetry/telemetry_test.go | 44 ++ pkg/cmd/api/api.go | 2 +- .../verify/verify_integration_test.go | 78 ++-- pkg/cmd/auth/login/login.go | 2 +- pkg/cmd/auth/refresh/refresh.go | 2 +- pkg/cmd/auth/setupgit/setupgit.go | 2 +- pkg/cmd/codespace/root.go | 10 +- pkg/cmd/factory/default.go | 149 ++---- pkg/cmd/factory/default_test.go | 403 +--------------- pkg/cmd/search/shared/shared_test.go | 13 +- pkg/cmd/skills/preview/preview.go | 18 +- pkg/cmd/skills/search/search.go | 24 +- pkg/cmdutil/factory.go | 78 +--- pkg/cmdutil/repo_override.go | 6 +- 23 files changed, 920 insertions(+), 656 deletions(-) create mode 100644 acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar rename pkg/cmdutil/factory_test.go => internal/ghcmd/executable_test.go (94%) diff --git a/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar new file mode 100644 index 00000000000..0fe6f4bb210 --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar @@ -0,0 +1,8 @@ +# GHES users should not get telemetry even when telemetry is enabled +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +env GH_ENTERPRISE_TOKEN=fake-enterprise-token + +exec gh version +! stderr 'Telemetry payload:' diff --git a/api/http_client.go b/api/http_client.go index 532f79c7f9d..be7a6b8a71c 100644 --- a/api/http_client.go +++ b/api/http_client.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/utils" ghAPI "github.com/cli/go-gh/v2/pkg/api" ghauth "github.com/cli/go-gh/v2/pkg/auth" @@ -26,6 +27,7 @@ type HTTPClientOptions struct { LogColorize bool LogVerboseHTTP bool SkipDefaultHeaders bool + TelemetryDisabler ghtelemetry.Disabler } func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) { @@ -74,6 +76,13 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) { client.Transport = AddAuthTokenHeader(client.Transport, opts.Config) } + if opts.TelemetryDisabler != nil { + client.Transport = telemetryDisablerTransport{ + wrappedTransport: client.Transport, + telemetryDisabler: opts.TelemetryDisabler, + } + } + return client, nil } @@ -147,3 +156,15 @@ func getHost(r *http.Request) string { } return r.URL.Host } + +type telemetryDisablerTransport struct { + wrappedTransport http.RoundTripper + telemetryDisabler ghtelemetry.Disabler +} + +func (t telemetryDisablerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if ghauth.IsEnterprise(getHost(req)) { + t.telemetryDisabler.Disable() + } + return t.wrappedTransport.RoundTrip(req) +} diff --git a/api/http_client_test.go b/api/http_client_test.go index 1c81b4aa7a5..198c0849118 100644 --- a/api/http_client_test.go +++ b/api/http_client_test.go @@ -315,6 +315,80 @@ func TestHTTPClientSanitizeControlCharactersC1(t *testing.T) { assert.Equal(t, "monalisa¡", issue.Author.Login) } +func TestNewHTTPClientTelemetryDisabler(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer ts.Close() + + tests := []struct { + name string + host string + wantDisabled bool + }{ + { + name: "enterprise host triggers disable", + host: "ghes.example.com", + wantDisabled: true, + }, + { + name: "github.com does not trigger disable", + host: "github.com", + wantDisabled: false, + }, + { + name: "tenancy host does not trigger disable", + host: "my-company.ghe.com", + wantDisabled: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + disabler := &fakeTelemetryDisabler{} + client, err := NewHTTPClient(HTTPClientOptions{ + TelemetryDisabler: disabler, + }) + require.NoError(t, err) + + req, err := http.NewRequest("GET", ts.URL, nil) + require.NoError(t, err) + req.Host = tt.host + + res, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, 204, res.StatusCode) + assert.Equal(t, tt.wantDisabled, disabler.disabled, "Disable() called") + }) + } +} + +func TestNewHTTPClientWithoutTelemetryDisabler(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer ts.Close() + + client, err := NewHTTPClient(HTTPClientOptions{}) + require.NoError(t, err) + + req, err := http.NewRequest("GET", ts.URL, nil) + require.NoError(t, err) + req.Host = "ghes.example.com" + + res, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, 204, res.StatusCode) +} + +type fakeTelemetryDisabler struct { + disabled bool +} + +func (f *fakeTelemetryDisabler) Disable() { + f.disabled = true +} + type tinyConfig map[string]string func (c tinyConfig) ActiveToken(host string) (string, string) { diff --git a/internal/gh/ghtelemetry/telemetry.go b/internal/gh/ghtelemetry/telemetry.go index c9256361b59..197b955b4c1 100644 --- a/internal/gh/ghtelemetry/telemetry.go +++ b/internal/gh/ghtelemetry/telemetry.go @@ -10,8 +10,13 @@ type Event struct { Measures Measures } +type Disabler interface { + Disable() +} + type EventRecorder interface { Record(event Event) + Disabler } type CommandRecorder interface { diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 9112d428372..eab842c5a79 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strconv" "strings" "time" @@ -20,6 +21,7 @@ import ( "github.com/cli/cli/v2/internal/build" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/config/migration" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/internal/update" @@ -28,6 +30,8 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/utils" + ghauth "github.com/cli/go-gh/v2/pkg/auth" + xcolor "github.com/cli/go-gh/v2/pkg/x/color" "github.com/cli/safeexec" "github.com/mgutz/ansi" "github.com/spf13/cobra" @@ -48,33 +52,34 @@ func Main() exitCode { buildVersion := build.Version hasDebug, _ := utils.IsDebugEnabled() - cmdFactory := factory.New(buildVersion, string(agents.Detect())) - stderr := cmdFactory.IOStreams.ErrOut - - cfg, err := cmdFactory.Config() + cfg, err := config.NewConfig() if err != nil { - fmt.Fprintf(stderr, "failed to load config: %s\n", err) + fmt.Fprintf(os.Stderr, "failed to load config: %s\n", err) return exitError } + ioStreams := newIOStreams(cfg) + stderr := ioStreams.ErrOut + + ghExecutablePath := executablePath("gh") + additionalCommonDimensions := ghtelemetry.Dimensions{ "version": strings.TrimPrefix(buildVersion, "v"), - "is_tty": strconv.FormatBool(cmdFactory.IOStreams.IsStdoutTTY()), + "is_tty": strconv.FormatBool(ioStreams.IsStdoutTTY()), "agent": string(agents.Detect()), } var telemetryService ghtelemetry.Service - if os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" { + if os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg) { telemetryService = &telemetry.NoOpService{} } else { - telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) switch telemetryState { case telemetry.Disabled: telemetryService = &telemetry.NoOpService{} case telemetry.Logged: telemetryService = telemetry.NewService( - telemetry.LogFlusher(cmdFactory.IOStreams.ErrOut, cmdFactory.IOStreams.ColorEnabled()), + telemetry.LogFlusher(ioStreams.ErrOut, ioStreams.ColorEnabled()), telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), ) case telemetry.Enabled: @@ -84,7 +89,7 @@ func Main() exitCode { } additionalCommonDimensions["sample_rate"] = strconv.Itoa(sampleRate) telemetryService = telemetry.NewService( - telemetry.GitHubFlusher(cmdFactory.Executable()), + telemetry.GitHubFlusher(ghExecutablePath), telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), telemetry.WithSampleRate(sampleRate), ) @@ -95,6 +100,8 @@ func Main() exitCode { } defer telemetryService.Flush() + cmdFactory := factory.New(buildVersion, string(agents.Detect()), cfg, ioStreams, ghExecutablePath, telemetryService) + var m migration.MultiAccount if err := cfg.Migrate(m); err != nil { fmt.Fprintln(stderr, err) @@ -211,7 +218,7 @@ func Main() exitCode { updateCancel() // if the update checker hasn't completed by now, abort it newRelease := <-updateMessageChan if newRelease != nil { - isHomebrew := isUnderHomebrew(cmdFactory.Executable()) + isHomebrew := isUnderHomebrew(cmdFactory.ExecutablePath) if isHomebrew && isRecentRelease(newRelease.PublishedAt) { // do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core return exitOK @@ -289,3 +296,148 @@ func isUnderHomebrew(ghBinary string) bool { brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator) return strings.HasPrefix(ghBinary, brewBinPrefix) } + +func newIOStreams(cfg gh.Config) *iostreams.IOStreams { + io := iostreams.System() + + if _, ghPromptDisabled := os.LookupEnv("GH_PROMPT_DISABLED"); ghPromptDisabled { + io.SetNeverPrompt(true) + } else if prompt := cfg.Prompt(""); prompt.Value == "disabled" { + io.SetNeverPrompt(true) + } + + falseyValues := []string{"false", "0", "no", ""} + + accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER") + if accessiblePrompterIsSet { + if !slices.Contains(falseyValues, accessiblePrompterValue) { + io.SetAccessiblePrompterEnabled(true) + } + } else if prompt := cfg.AccessiblePrompter(""); prompt.Value == "enabled" { + io.SetAccessiblePrompterEnabled(true) + } + + experimentalPrompterValue, experimentalPrompterIsSet := os.LookupEnv("GH_EXPERIMENTAL_PROMPTER") + if experimentalPrompterIsSet { + if !slices.Contains(falseyValues, experimentalPrompterValue) { + io.SetExperimentalPrompterEnabled(true) + } + } + + ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED") + if ghSpinnerDisabledIsSet { + if !slices.Contains(falseyValues, ghSpinnerDisabledValue) { + io.SetSpinnerDisabled(true) + } + } else if spinnerDisabled := cfg.Spinner(""); spinnerDisabled.Value == "disabled" { + io.SetSpinnerDisabled(true) + } + + // Pager precedence + // 1. GH_PAGER + // 2. pager from config + // 3. PAGER + if ghPager, ghPagerExists := os.LookupEnv("GH_PAGER"); ghPagerExists { + io.SetPager(ghPager) + } else if pager := cfg.Pager(""); pager.Value != "" { + io.SetPager(pager.Value) + } + + if ghColorLabels, ghColorLabelsExists := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabelsExists { + switch ghColorLabels { + case "", "0", "false", "no": + io.SetColorLabels(false) + default: + io.SetColorLabels(true) + } + } else if prompt := cfg.ColorLabels(""); prompt.Value == "enabled" { + io.SetColorLabels(true) + } + + io.SetAccessibleColorsEnabled(xcolor.IsAccessibleColorsEnabled()) + + return io +} + +// Executable is the path to the currently invoked binary +func executablePath(executableName string) string { + ghPath := os.Getenv("GH_PATH") + if ghPath != "" { + return ghPath + } + + if strings.ContainsRune(executableName, os.PathSeparator) { + return executableName + } + + return executable(executableName) +} + +// Finds the location of the executable for the current process as it's found in PATH, respecting symlinks. +// If the process couldn't determine its location, return fallbackName. If the executable wasn't found in +// PATH, return the absolute location to the program. +// +// The idea is that the result of this function is callable in the future and refers to the same +// installation of gh, even across upgrades. This is needed primarily for Homebrew, which installs software +// under a location such as `/usr/local/Cellar/gh/1.13.1/bin/gh` and symlinks it from `/usr/local/bin/gh`. +// When the version is upgraded, Homebrew will often delete older versions, but keep the symlink. Because of +// this, we want to refer to the `gh` binary as `/usr/local/bin/gh` and not as its internal Homebrew +// location. +// +// None of this would be needed if we could just refer to GitHub CLI as `gh`, i.e. without using an absolute +// path. However, for some reason Homebrew does not include `/usr/local/bin` in PATH when it invokes git +// commands to update its taps. If `gh` (no path) is being used as git credential helper, as set up by `gh +// auth login`, running `brew update` will print out authentication errors as git is unable to locate +// Homebrew-installed `gh` +func executable(fallback string) string { + exe, err := os.Executable() + if err != nil { + return fallback + } + + base := filepath.Base(exe) + path := os.Getenv("PATH") + for _, dir := range filepath.SplitList(path) { + p, err := filepath.Abs(filepath.Join(dir, base)) + if err != nil { + continue + } + f, err := os.Lstat(p) + if err != nil { + continue + } + + if p == exe { + return p + } else if f.Mode()&os.ModeSymlink != 0 { + realP, err := filepath.EvalSymlinks(p) + if err != nil { + continue + } + realExe, err := filepath.EvalSymlinks(exe) + if err != nil { + continue + } + if realP == realExe { + return p + } + } + } + + return exe +} + +func mightBeGHESUser(cfg gh.Config) bool { + if os.Getenv("GH_ENTERPRISE_TOKEN") != "" || os.Getenv("GITHUB_ENTERPRISE_TOKEN") != "" { + return true + } + + if host := os.Getenv("GH_HOST"); host != "" && ghauth.IsEnterprise(host) { + return true + } + + // If any targeted host is Enterprise, then the user is likely a GHES user. + return slices.ContainsFunc(cfg.Authentication().Hosts(), func(host string) bool { + return ghauth.IsEnterprise(host) + }) +} diff --git a/internal/ghcmd/cmd_test.go b/internal/ghcmd/cmd_test.go index 08bbceb8532..65bcc0f288e 100644 --- a/internal/ghcmd/cmd_test.go +++ b/internal/ghcmd/cmd_test.go @@ -7,8 +7,11 @@ import ( "net" "testing" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" ) func Test_printError(t *testing.T) { @@ -76,3 +79,435 @@ check your internet connection or https://githubstatus.com }) } } + +func Test_newIOStreams_pager(t *testing.T) { + tests := []struct { + name string + env map[string]string + config gh.Config + wantPager string + }{ + { + name: "GH_PAGER and PAGER set", + env: map[string]string{ + "GH_PAGER": "GH_PAGER", + "PAGER": "PAGER", + }, + wantPager: "GH_PAGER", + }, + { + name: "GH_PAGER and config pager set", + env: map[string]string{ + "GH_PAGER": "GH_PAGER", + }, + config: pagerConfig(), + wantPager: "GH_PAGER", + }, + { + name: "config pager and PAGER set", + env: map[string]string{ + "PAGER": "PAGER", + }, + config: pagerConfig(), + wantPager: "CONFIG_PAGER", + }, + { + name: "only PAGER set", + env: map[string]string{ + "PAGER": "PAGER", + }, + wantPager: "PAGER", + }, + { + name: "GH_PAGER set to blank string", + env: map[string]string{ + "GH_PAGER": "", + "PAGER": "PAGER", + }, + wantPager: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != nil { + for k, v := range tt.env { + t.Setenv(k, v) + } + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.wantPager, io.GetPager()) + }) + } +} + +func Test_newIOStreams_prompt(t *testing.T) { + tests := []struct { + name string + config gh.Config + promptDisabled bool + env map[string]string + }{ + { + name: "default config", + promptDisabled: false, + }, + { + name: "config with prompt disabled", + config: disablePromptConfig(), + promptDisabled: true, + }, + { + name: "prompt disabled via GH_PROMPT_DISABLED env var", + env: map[string]string{"GH_PROMPT_DISABLED": "1"}, + promptDisabled: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != nil { + for k, v := range tt.env { + t.Setenv(k, v) + } + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.promptDisabled, io.GetNeverPrompt()) + }) + } +} + +func Test_newIOStreams_spinnerDisabled(t *testing.T) { + tests := []struct { + name string + config gh.Config + spinnerDisabled bool + env map[string]string + }{ + { + name: "default config", + spinnerDisabled: false, + }, + { + name: "config with spinner disabled", + config: disableSpinnersConfig(), + spinnerDisabled: true, + }, + { + name: "config with spinner enabled", + config: enableSpinnersConfig(), + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = 0", + env: map[string]string{"GH_SPINNER_DISABLED": "0"}, + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = false", + env: map[string]string{"GH_SPINNER_DISABLED": "false"}, + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = no", + env: map[string]string{"GH_SPINNER_DISABLED": "no"}, + spinnerDisabled: false, + }, + { + name: "spinner enabled via GH_SPINNER_DISABLED env var = 1", + env: map[string]string{"GH_SPINNER_DISABLED": "1"}, + spinnerDisabled: true, + }, + { + name: "spinner enabled via GH_SPINNER_DISABLED env var = true", + env: map[string]string{"GH_SPINNER_DISABLED": "true"}, + spinnerDisabled: true, + }, + { + name: "config enabled but env disabled, respects env", + config: enableSpinnersConfig(), + env: map[string]string{"GH_SPINNER_DISABLED": "true"}, + spinnerDisabled: true, + }, + { + name: "config disabled but env enabled, respects env", + config: disableSpinnersConfig(), + env: map[string]string{"GH_SPINNER_DISABLED": "false"}, + spinnerDisabled: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.spinnerDisabled, io.GetSpinnerDisabled()) + }) + } +} + +func Test_newIOStreams_accessiblePrompterEnabled(t *testing.T) { + tests := []struct { + name string + config gh.Config + accessiblePrompterEnabled bool + env map[string]string + }{ + { + name: "default config", + accessiblePrompterEnabled: false, + }, + { + name: "config with accessible prompter enabled", + config: enableAccessiblePrompterConfig(), + accessiblePrompterEnabled: true, + }, + { + name: "config with accessible prompter disabled", + config: disableAccessiblePrompterConfig(), + accessiblePrompterEnabled: false, + }, + { + name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = 1", + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "1"}, + accessiblePrompterEnabled: true, + }, + { + name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = true", + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, + accessiblePrompterEnabled: true, + }, + { + name: "accessible prompter disabled via GH_ACCESSIBLE_PROMPTER env var = 0", + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "0"}, + accessiblePrompterEnabled: false, + }, + { + name: "config disabled but env enabled, respects env", + config: disableAccessiblePrompterConfig(), + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, + accessiblePrompterEnabled: true, + }, + { + name: "config enabled but env disabled, respects env", + config: enableAccessiblePrompterConfig(), + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "false"}, + accessiblePrompterEnabled: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.accessiblePrompterEnabled, io.AccessiblePrompterEnabled()) + }) + } +} + +func Test_newIOStreams_colorLabels(t *testing.T) { + tests := []struct { + name string + config gh.Config + colorLabelsEnabled bool + env map[string]string + }{ + { + name: "default config", + colorLabelsEnabled: false, + }, + { + name: "config with colorLabels enabled", + config: enableColorLabelsConfig(), + colorLabelsEnabled: true, + }, + { + name: "config with colorLabels disabled", + config: disableColorLabelsConfig(), + colorLabelsEnabled: false, + }, + { + name: "colorLabels enabled via `1` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "1"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels enabled via `true` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "true"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels enabled via `yes` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "yes"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels disable via empty string in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": ""}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `0` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "0"}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `false` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "false"}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `no` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "no"}, + colorLabelsEnabled: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != nil { + for k, v := range tt.env { + t.Setenv(k, v) + } + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.colorLabelsEnabled, io.ColorLabels()) + }) + } +} + +func Test_mightBeGHESUser(t *testing.T) { + tests := []struct { + name string + env map[string]string + config gh.Config + want bool + }{ + { + name: "GH_ENTERPRISE_TOKEN set", + env: map[string]string{"GH_ENTERPRISE_TOKEN": "some-token"}, + config: config.NewBlankConfig(), + want: true, + }, + { + name: "GITHUB_ENTERPRISE_TOKEN set", + env: map[string]string{"GITHUB_ENTERPRISE_TOKEN": "some-token"}, + config: config.NewBlankConfig(), + want: true, + }, + { + name: "no env vars, config has enterprise host", + config: config.NewFromString("hosts:\n ghes.example.com:\n oauth_token: abc123\n"), + want: true, + }, + { + name: "no env vars, config has only github.com", + config: config.NewFromString("hosts:\n github.com:\n oauth_token: abc123\n"), + want: false, + }, + { + name: "no env vars, config has no hosts", + config: config.NewBlankConfig(), + want: false, + }, + { + name: "no env vars, config has github.com and enterprise host", + config: config.NewFromString("hosts:\n github.com:\n oauth_token: abc123\n ghes.example.com:\n oauth_token: def456\n"), + want: true, + }, + { + name: "no env vars, config has tenancy host", + config: config.NewFromString("hosts:\n my-company.ghe.com:\n oauth_token: abc123\n"), + want: false, + }, + { + name: "GH_HOST set to enterprise host", + env: map[string]string{"GH_HOST": "ghes.example.com"}, + config: config.NewBlankConfig(), + want: true, + }, + { + name: "GH_HOST set to github.com", + env: map[string]string{"GH_HOST": "github.com"}, + config: config.NewBlankConfig(), + want: false, + }, + { + name: "GH_HOST set to tenancy host", + env: map[string]string{"GH_HOST": "my-company.ghe.com"}, + config: config.NewBlankConfig(), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + got := mightBeGHESUser(tt.config) + assert.Equal(t, tt.want, got) + }) + } +} + +func pagerConfig() gh.Config { + return config.NewFromString("pager: CONFIG_PAGER") +} + +func disablePromptConfig() gh.Config { + return config.NewFromString("prompt: disabled") +} + +func enableAccessiblePrompterConfig() gh.Config { + return config.NewFromString("accessible_prompter: enabled") +} + +func disableAccessiblePrompterConfig() gh.Config { + return config.NewFromString("accessible_prompter: disabled") +} + +func disableSpinnersConfig() gh.Config { + return config.NewFromString("spinner: disabled") +} + +func enableSpinnersConfig() gh.Config { + return config.NewFromString("spinner: enabled") +} + +func disableColorLabelsConfig() gh.Config { + return config.NewFromString("color_labels: disabled") +} + +func enableColorLabelsConfig() gh.Config { + return config.NewFromString("color_labels: enabled") +} diff --git a/pkg/cmdutil/factory_test.go b/internal/ghcmd/executable_test.go similarity index 94% rename from pkg/cmdutil/factory_test.go rename to internal/ghcmd/executable_test.go index 0103a04f1b5..f0374429bcd 100644 --- a/pkg/cmdutil/factory_test.go +++ b/internal/ghcmd/executable_test.go @@ -1,10 +1,12 @@ -package cmdutil +package ghcmd import ( "os" "path/filepath" "strings" "testing" + + "github.com/stretchr/testify/require" ) func Test_executable(t *testing.T) { @@ -113,11 +115,8 @@ func Test_executable_relative(t *testing.T) { } } -func Test_Executable_override(t *testing.T) { +func TestExecutablePath(t *testing.T) { override := strings.Join([]string{"C:", "cygwin64", "home", "gh.exe"}, string(os.PathSeparator)) t.Setenv("GH_PATH", override) - f := Factory{} - if got := f.Executable(); got != override { - t.Errorf("executable() = %q, want %q", got, override) - } + require.Equal(t, override, executablePath("gh")) } diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go index ee38262d9d5..1dc45ab2692 100644 --- a/internal/telemetry/fake.go +++ b/internal/telemetry/fake.go @@ -10,4 +10,6 @@ func (r *EventRecorderSpy) Record(event ghtelemetry.Event) { r.Events = append(r.Events, event) } +func (r *EventRecorderSpy) Disable() {} + func (r *EventRecorderSpy) Flush() {} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index f8698706ac3..b046ec77d84 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -248,6 +248,15 @@ type service struct { sampleBucket byte events []recordedEvent + + disabled bool +} + +func (s *service) Disable() { + s.mu.Lock() + defer s.mu.Unlock() + + s.disabled = true } func (s *service) Record(event ghtelemetry.Event) { @@ -269,6 +278,10 @@ func (s *service) Flush() { s.mu.Lock() defer s.mu.Unlock() + if s.disabled { + return + } + if s.previouslyCalled { return } @@ -379,6 +392,8 @@ type NoOpService struct{} func (s *NoOpService) Record(event ghtelemetry.Event) {} +func (s *NoOpService) Disable() {} + func (s *NoOpService) SetSampleRate(rate int) {} func (s *NoOpService) Flush() {} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 0142d4d1611..207d611eeca 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -598,10 +598,54 @@ func TestWithAdditionalCommonDimensions(t *testing.T) { assert.NotEmpty(t, captured.Events[0].Dimensions["architecture"]) } +func TestServiceDisable(t *testing.T) { + t.Run("prevents flush from sending events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Disable() + svc.Flush() + + assert.False(t, called, "flusher should not be called after Disable()") + }) + + t.Run("prevents flush even with multiple recorded events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + + svc.Record(ghtelemetry.Event{Type: "event1"}) + svc.Record(ghtelemetry.Event{Type: "event2"}) + svc.Record(ghtelemetry.Event{Type: "event3"}) + svc.Disable() + svc.Flush() + + assert.False(t, called, "flusher should not be called after Disable()") + }) + + t.Run("can be called before any events are recorded", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + + svc.Disable() + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + assert.False(t, called, "flusher should not be called when disabled before recording") + }) +} + func TestNoOpService(t *testing.T) { svc := &NoOpService{} // All methods should be safe to call without panicking svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Disable() svc.SetSampleRate(50) svc.Flush() } diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index fb641457f0f..4a87e0f8cb9 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -223,7 +223,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command }, Args: cobra.ExactArgs(1), PreRun: func(c *cobra.Command, args []string) { - opts.BaseRepo = cmdutil.OverrideBaseRepoFunc(f, "") + opts.BaseRepo = cmdutil.OverrideBaseRepoFunc(f.BaseRepo, "") }, RunE: func(c *cobra.Command, args []string) error { opts.RequestPath = args[0] diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index ec64cefa7a3..10a1e521657 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -1,17 +1,19 @@ -//go:build integration - package verify import ( "net/http" "testing" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/test" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmd/factory" + "github.com/cli/cli/v2/pkg/iostreams" o "github.com/cli/cli/v2/pkg/option" "github.com/cli/go-gh/v2/pkg/auth" "github.com/stretchr/testify/require" @@ -26,12 +28,15 @@ func TestVerifyIntegration(t *testing.T) { TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test", "") - - hc, err := cmdFactory.HttpClient() - if err != nil { - t.Fatal(err) - } + ios, _, _, _ := iostreams.Test() + hc, err := factory.HttpClientFunc( + &config.AuthConfig{}, + ios, + "test", + "", + &telemetry.NoOpService{}, + )() + require.NoError(t, err) host, _ := auth.DefaultHost() @@ -143,12 +148,15 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test", "") - - hc, err := cmdFactory.HttpClient() - if err != nil { - t.Fatal(err) - } + ios, _, _, _ := iostreams.Test() + hc, err := factory.HttpClientFunc( + &config.AuthConfig{}, + ios, + "test", + "", + &telemetry.NoOpService{}, + )() + require.NoError(t, err) host, _ := auth.DefaultHost() @@ -217,12 +225,16 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test", "") - - hc, err := cmdFactory.HttpClient() - if err != nil { - t.Fatal(err) - } + cfg := config.NewBlankConfig() + ios, _, _, _ := iostreams.Test() + hc, err := factory.HttpClientFunc( + cfg.Authentication(), + ios, + "test", + "", + &telemetry.NoOpService{}, + )() + require.NoError(t, err) host, _ := auth.DefaultHost() @@ -310,22 +322,28 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test", "") - - hc, err := cmdFactory.HttpClient() - if err != nil { - t.Fatal(err) - } + cfg := config.NewBlankConfig() + ios, _, _, _ := iostreams.Test() + hc, err := factory.HttpClientFunc( + cfg.Authentication(), + ios, + "test", + "", + &telemetry.NoOpService{}, + )() + require.NoError(t, err) host, _ := auth.DefaultHost() sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) require.NoError(t, err) baseOpts := Options{ - APIClient: api.NewLiveClient(hc, host, logger), - ArtifactPath: artifactPath, - BundlePath: bundlePath, - Config: cmdFactory.Config, + APIClient: api.NewLiveClient(hc, host, logger), + ArtifactPath: artifactPath, + BundlePath: bundlePath, + Config: func() (gh.Config, error) { + return cfg, nil + }, DigestAlgorithm: "sha256", Logger: logger, OCIClient: oci.NewLiveClient(), diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 88bc09f63a0..24d30c56244 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -138,7 +138,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm opts.Hostname, _ = ghauth.DefaultHost() } - opts.MainExecutable = f.Executable() + opts.MainExecutable = f.ExecutablePath if runF != nil { return runF(opts) } diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index c025df465b8..842902502cd 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -101,7 +101,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. return cmdutil.FlagErrorf("--hostname required when not running interactively") } - opts.MainExecutable = f.Executable() + opts.MainExecutable = f.ExecutablePath if runF != nil { return runF(opts) } diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go index 0ff7b690360..a146a579fb9 100644 --- a/pkg/cmd/auth/setupgit/setupgit.go +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -53,7 +53,7 @@ func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobr `), RunE: func(cmd *cobra.Command, args []string) error { opts.CredentialsHelperConfig = &gitcredentials.HelperConfig{ - SelfExecutablePath: f.Executable(), + SelfExecutablePath: f.ExecutablePath, GitClient: f.GitClient, } if opts.Hostname == "" && opts.Force { diff --git a/pkg/cmd/codespace/root.go b/pkg/cmd/codespace/root.go index d1675a8f742..5d3bff3d6d8 100644 --- a/pkg/cmd/codespace/root.go +++ b/pkg/cmd/codespace/root.go @@ -7,6 +7,14 @@ import ( "github.com/spf13/cobra" ) +type ghExecutable struct { + executablePath string +} + +func (e *ghExecutable) Executable() string { + return e.executablePath +} + func NewCmdCodespace(f *cmdutil.Factory) *cobra.Command { root := &cobra.Command{ Use: "codespace", @@ -17,7 +25,7 @@ func NewCmdCodespace(f *cmdutil.Factory) *cobra.Command { app := NewApp( f.IOStreams, - f, + &ghExecutable{executablePath: f.ExecutablePath}, codespacesAPI.New(f), f.Browser, f.Remotes, diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 7afc6baa758..bf203bd4314 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -4,46 +4,46 @@ import ( "context" "fmt" "net/http" - "os" "regexp" - "slices" "time" "github.com/cli/cli/v2/api" ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" - "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/extension" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - xcolor "github.com/cli/go-gh/v2/pkg/x/color" ) var ssoHeader string var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`) -func New(appVersion string, invokingAgent string) *cmdutil.Factory { +func New(appVersion string, invokingAgent string, cfg gh.Config, ios *iostreams.IOStreams, executablePath string, telemetryDisabler ghtelemetry.Disabler) *cmdutil.Factory { f := &cmdutil.Factory{ - AppVersion: appVersion, - InvokingAgent: invokingAgent, - Config: configFunc(), // No factory dependencies - ExecutableName: "gh", + AppVersion: appVersion, + InvokingAgent: invokingAgent, + Cfg: cfg, + Config: func() (gh.Config, error) { + return cfg, nil + }, // No factory dependencies + ExecutablePath: executablePath, } - f.IOStreams = ioStreams(f) // Depends on Config - f.HttpClient = httpClientFunc(f, appVersion, invokingAgent) // Depends on Config, IOStreams, appVersion, and invokingAgent - f.PlainHttpClient = plainHttpClientFunc(f, appVersion, invokingAgent) // Depends on IOStreams, appVersion, and invokingAgent - f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable - f.Remotes = remotesFunc(f) // Depends on Config, and GitClient - f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes - f.Prompter = newPrompter(f) // Depends on Config and IOStreams - f.Browser = newBrowser(f) // Depends on Config, and IOStreams - f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams - f.Branch = branchFunc(f) // Depends on GitClient + f.IOStreams = ios + f.HttpClient = HttpClientFunc(cfg.Authentication(), ios, appVersion, invokingAgent, telemetryDisabler) + f.PlainHttpClient = plainHttpClientFunc(ios, appVersion, invokingAgent, telemetryDisabler) + f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable + f.Remotes = remotesFunc(f) // Depends on Config, and GitClient + f.BaseRepo = BaseRepoFunc(f.Remotes) + f.Prompter = newPrompter(f) // Depends on Config and IOStreams + f.Browser = newBrowser(f) // Depends on Config, and IOStreams + f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams + f.Branch = branchFunc(f) // Depends on GitClient return f } @@ -73,9 +73,9 @@ func New(appVersion string, invokingAgent string) *cmdutil.Factory { // origin https://github.com/cli/cli-fork.git (push) // // With this resolution function, the upstream will always be chosen (assuming we have authenticated with github.com). -func BaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) { +func BaseRepoFunc(remotesFunc func() (ghContext.Remotes, error)) func() (ghrepo.Interface, error) { return func() (ghrepo.Interface, error) { - remotes, err := f.Remotes() + remotes, err := remotesFunc() if err != nil { return nil, err } @@ -187,19 +187,15 @@ func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) { return rr.Resolver() } -func httpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent string) func() (*http.Client, error) { +func HttpClientFunc(authCfg gh.AuthConfig, ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) func() (*http.Client, error) { return func() (*http.Client, error) { - io := f.IOStreams - cfg, err := f.Config() - if err != nil { - return nil, err - } opts := api.HTTPClientOptions{ - Config: cfg.Authentication(), - Log: io.ErrOut, - LogColorize: io.ColorEnabled(), - AppVersion: appVersion, - InvokingAgent: invokingAgent, + Config: authCfg, + Log: ios.ErrOut, + LogColorize: ios.ColorEnabled(), + AppVersion: appVersion, + InvokingAgent: invokingAgent, + TelemetryDisabler: telemetryDisabler, } client, err := api.NewHTTPClient(opts) if err != nil { @@ -210,16 +206,16 @@ func httpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent string) } } -func plainHttpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent string) func() (*http.Client, error) { +func plainHttpClientFunc(ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) func() (*http.Client, error) { return func() (*http.Client, error) { - io := f.IOStreams opts := api.HTTPClientOptions{ - Log: io.ErrOut, - LogColorize: io.ColorEnabled(), + Log: ios.ErrOut, + LogColorize: ios.ColorEnabled(), AppVersion: appVersion, InvokingAgent: invokingAgent, // This is required to prevent automatic setting of auth and other headers. SkipDefaultHeaders: true, + TelemetryDisabler: telemetryDisabler, } client, err := api.NewHTTPClient(opts) if err != nil { @@ -231,9 +227,8 @@ func plainHttpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent st func newGitClient(f *cmdutil.Factory) *git.Client { io := f.IOStreams - ghPath := f.Executable() client := &git.Client{ - GhPath: ghPath, + GhPath: f.ExecutablePath, Stderr: io.ErrOut, Stdin: io.In, Stdout: io.Out, @@ -252,18 +247,6 @@ func newPrompter(f *cmdutil.Factory) prompter.Prompter { return prompter.New(editor, io) } -func configFunc() func() (gh.Config, error) { - var cachedConfig gh.Config - var configError error - return func() (gh.Config, error) { - if cachedConfig != nil || configError != nil { - return cachedConfig, configError - } - cachedConfig, configError = config.NewConfig() - return cachedConfig, configError - } -} - func branchFunc(f *cmdutil.Factory) func() (string, error) { return func() (string, error) { currentBranch, err := f.GitClient.CurrentBranch(context.Background()) @@ -293,72 +276,6 @@ func extensionManager(f *cmdutil.Factory) *extension.Manager { return em } -func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams { - io := iostreams.System() - cfg, err := f.Config() - if err != nil { - return io - } - - if _, ghPromptDisabled := os.LookupEnv("GH_PROMPT_DISABLED"); ghPromptDisabled { - io.SetNeverPrompt(true) - } else if prompt := cfg.Prompt(""); prompt.Value == "disabled" { - io.SetNeverPrompt(true) - } - - falseyValues := []string{"false", "0", "no", ""} - - accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER") - if accessiblePrompterIsSet { - if !slices.Contains(falseyValues, accessiblePrompterValue) { - io.SetAccessiblePrompterEnabled(true) - } - } else if prompt := cfg.AccessiblePrompter(""); prompt.Value == "enabled" { - io.SetAccessiblePrompterEnabled(true) - } - - experimentalPrompterValue, experimentalPrompterIsSet := os.LookupEnv("GH_EXPERIMENTAL_PROMPTER") - if experimentalPrompterIsSet { - if !slices.Contains(falseyValues, experimentalPrompterValue) { - io.SetExperimentalPrompterEnabled(true) - } - } - - ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED") - if ghSpinnerDisabledIsSet { - if !slices.Contains(falseyValues, ghSpinnerDisabledValue) { - io.SetSpinnerDisabled(true) - } - } else if spinnerDisabled := cfg.Spinner(""); spinnerDisabled.Value == "disabled" { - io.SetSpinnerDisabled(true) - } - - // Pager precedence - // 1. GH_PAGER - // 2. pager from config - // 3. PAGER - if ghPager, ghPagerExists := os.LookupEnv("GH_PAGER"); ghPagerExists { - io.SetPager(ghPager) - } else if pager := cfg.Pager(""); pager.Value != "" { - io.SetPager(pager.Value) - } - - if ghColorLabels, ghColorLabelsExists := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabelsExists { - switch ghColorLabels { - case "", "0", "false", "no": - io.SetColorLabels(false) - default: - io.SetColorLabels(true) - } - } else if prompt := cfg.ColorLabels(""); prompt.Value == "enabled" { - io.SetColorLabels(true) - } - - io.SetAccessibleColorsEnabled(xcolor.IsAccessibleColorsEnabled()) - - return io -} - // SSOURL returns the URL of a SAML SSO challenge received by the server for clients that use ExtractHeader // to extract the value of the "X-GitHub-SSO" response header. func SSOURL() string { diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index 7d84caa8f26..6f376e48c88 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" ghmock "github.com/cli/cli/v2/internal/gh/mock" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -66,7 +67,6 @@ func Test_BaseRepo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1", "") rr := &remoteResolver{ readRemotes: func() (git.RemoteSet, error) { return tt.remotes, nil @@ -90,8 +90,10 @@ func Test_BaseRepo(t *testing.T) { return cfg, nil }, } - f.Remotes = rr.Resolver() - f.BaseRepo = BaseRepoFunc(f) + remotes := rr.Resolver() + f := &cmdutil.Factory{ + BaseRepo: BaseRepoFunc(remotes), + } repo, err := f.BaseRepo() if tt.wantsErr { assert.Error(t, err) @@ -204,7 +206,7 @@ func Test_SmartBaseRepo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1", "") + f := &cmdutil.Factory{} rr := &remoteResolver{ readRemotes: func() (git.RemoteSet, error) { return tt.remotes, nil @@ -297,7 +299,6 @@ func Test_OverrideBaseRepo(t *testing.T) { if tt.envOverride != "" { t.Setenv("GH_REPO", tt.envOverride) } - f := New("1", "") rr := &remoteResolver{ readRemotes: func() (git.RemoteSet, error) { return tt.remotes, nil @@ -306,8 +307,10 @@ func Test_OverrideBaseRepo(t *testing.T) { return tt.config, nil }, } - f.Remotes = rr.Resolver() - f.BaseRepo = cmdutil.OverrideBaseRepoFunc(f, tt.argOverride) + remotes := rr.Resolver() + f := &cmdutil.Factory{ + BaseRepo: cmdutil.OverrideBaseRepoFunc(BaseRepoFunc(remotes), tt.argOverride), + } repo, err := f.BaseRepo() if tt.wantsErr { assert.Error(t, err) @@ -321,341 +324,6 @@ func Test_OverrideBaseRepo(t *testing.T) { } } -func Test_ioStreams_pager(t *testing.T) { - tests := []struct { - name string - env map[string]string - config gh.Config - wantPager string - }{ - { - name: "GH_PAGER and PAGER set", - env: map[string]string{ - "GH_PAGER": "GH_PAGER", - "PAGER": "PAGER", - }, - wantPager: "GH_PAGER", - }, - { - name: "GH_PAGER and config pager set", - env: map[string]string{ - "GH_PAGER": "GH_PAGER", - }, - config: pagerConfig(), - wantPager: "GH_PAGER", - }, - { - name: "config pager and PAGER set", - env: map[string]string{ - "PAGER": "PAGER", - }, - config: pagerConfig(), - wantPager: "CONFIG_PAGER", - }, - { - name: "only PAGER set", - env: map[string]string{ - "PAGER": "PAGER", - }, - wantPager: "PAGER", - }, - { - name: "GH_PAGER set to blank string", - env: map[string]string{ - "GH_PAGER": "", - "PAGER": "PAGER", - }, - wantPager: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.env != nil { - for k, v := range tt.env { - t.Setenv(k, v) - } - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.wantPager, io.GetPager()) - }) - } -} - -func Test_ioStreams_prompt(t *testing.T) { - tests := []struct { - name string - config gh.Config - promptDisabled bool - env map[string]string - }{ - { - name: "default config", - promptDisabled: false, - }, - { - name: "config with prompt disabled", - config: disablePromptConfig(), - promptDisabled: true, - }, - { - name: "prompt disabled via GH_PROMPT_DISABLED env var", - env: map[string]string{"GH_PROMPT_DISABLED": "1"}, - promptDisabled: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.env != nil { - for k, v := range tt.env { - t.Setenv(k, v) - } - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.promptDisabled, io.GetNeverPrompt()) - }) - } -} - -func Test_ioStreams_spinnerDisabled(t *testing.T) { - tests := []struct { - name string - config gh.Config - spinnerDisabled bool - env map[string]string - }{ - { - name: "default config", - spinnerDisabled: false, - }, - { - name: "config with spinner disabled", - config: disableSpinnersConfig(), - spinnerDisabled: true, - }, - { - name: "config with spinner enabled", - config: enableSpinnersConfig(), - spinnerDisabled: false, - }, - { - name: "spinner disabled via GH_SPINNER_DISABLED env var = 0", - env: map[string]string{"GH_SPINNER_DISABLED": "0"}, - spinnerDisabled: false, - }, - { - name: "spinner disabled via GH_SPINNER_DISABLED env var = false", - env: map[string]string{"GH_SPINNER_DISABLED": "false"}, - spinnerDisabled: false, - }, - { - name: "spinner disabled via GH_SPINNER_DISABLED env var = no", - env: map[string]string{"GH_SPINNER_DISABLED": "no"}, - spinnerDisabled: false, - }, - { - name: "spinner enabled via GH_SPINNER_DISABLED env var = 1", - env: map[string]string{"GH_SPINNER_DISABLED": "1"}, - spinnerDisabled: true, - }, - { - name: "spinner enabled via GH_SPINNER_DISABLED env var = true", - env: map[string]string{"GH_SPINNER_DISABLED": "true"}, - spinnerDisabled: true, - }, - { - name: "config enabled but env disabled, respects env", - config: enableSpinnersConfig(), - env: map[string]string{"GH_SPINNER_DISABLED": "true"}, - spinnerDisabled: true, - }, - { - name: "config disabled but env enabled, respects env", - config: disableSpinnersConfig(), - env: map[string]string{"GH_SPINNER_DISABLED": "false"}, - spinnerDisabled: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for k, v := range tt.env { - t.Setenv(k, v) - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.spinnerDisabled, io.GetSpinnerDisabled()) - }) - } -} - -func Test_ioStreams_accessiblePrompterEnabled(t *testing.T) { - tests := []struct { - name string - config gh.Config - accessiblePrompterEnabled bool - env map[string]string - }{ - { - name: "default config", - accessiblePrompterEnabled: false, - }, - { - name: "config with accessible prompter enabled", - config: enableAccessiblePrompterConfig(), - accessiblePrompterEnabled: true, - }, - { - name: "config with accessible prompter disabled", - config: disableAccessiblePrompterConfig(), - accessiblePrompterEnabled: false, - }, - { - name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = 1", - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "1"}, - accessiblePrompterEnabled: true, - }, - { - name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = true", - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, - accessiblePrompterEnabled: true, - }, - { - name: "accessible prompter disabled via GH_ACCESSIBLE_PROMPTER env var = 0", - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "0"}, - accessiblePrompterEnabled: false, - }, - { - name: "config disabled but env enabled, respects env", - config: disableAccessiblePrompterConfig(), - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, - accessiblePrompterEnabled: true, - }, - { - name: "config enabled but env disabled, respects env", - config: enableAccessiblePrompterConfig(), - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "false"}, - accessiblePrompterEnabled: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for k, v := range tt.env { - t.Setenv(k, v) - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.accessiblePrompterEnabled, io.AccessiblePrompterEnabled()) - }) - } -} - -func Test_ioStreams_colorLabels(t *testing.T) { - tests := []struct { - name string - config gh.Config - colorLabelsEnabled bool - env map[string]string - }{ - { - name: "default config", - colorLabelsEnabled: false, - }, - { - name: "config with colorLabels enabled", - config: enableColorLabelsConfig(), - colorLabelsEnabled: true, - }, - { - name: "config with colorLabels disabled", - config: disableColorLabelsConfig(), - colorLabelsEnabled: false, - }, - { - name: "colorLabels enabled via `1` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "1"}, - colorLabelsEnabled: true, - }, - { - name: "colorLabels enabled via `true` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "true"}, - colorLabelsEnabled: true, - }, - { - name: "colorLabels enabled via `yes` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "yes"}, - colorLabelsEnabled: true, - }, - { - name: "colorLabels disable via empty string in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": ""}, - colorLabelsEnabled: false, - }, - { - name: "colorLabels disabled via `0` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "0"}, - colorLabelsEnabled: false, - }, - { - name: "colorLabels disabled via `false` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "false"}, - colorLabelsEnabled: false, - }, - { - name: "colorLabels disabled via `no` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "no"}, - colorLabelsEnabled: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.env != nil { - for k, v := range tt.env { - t.Setenv(k, v) - } - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.colorLabelsEnabled, io.ColorLabels()) - }) - } -} - func TestSSOURL(t *testing.T) { tests := []struct { name string @@ -683,13 +351,9 @@ func TestSSOURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1", "") - f.Config = func() (gh.Config, error) { - return config.NewBlankConfig(), nil - } + cfg := config.NewBlankConfig() ios, _, _, stderr := iostreams.Test() - f.IOStreams = ios - client, err := httpClientFunc(f, "v1.2.3", "")() + client, err := HttpClientFunc(cfg.Authentication(), ios, "v1.2.3", "", &telemetry.NoOpService{})() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) if tt.sso != "" { @@ -718,13 +382,8 @@ func TestPlainHttpClient(t *testing.T) { })) defer ts.Close() - f := New("1", "") - f.Config = func() (gh.Config, error) { - return config.NewBlankConfig(), nil - } ios, _, _, _ := iostreams.Test() - f.IOStreams = ios - client, err := plainHttpClientFunc(f, "v1.2.3", "")() + client, err := plainHttpClientFunc(ios, "v1.2.3", "", &telemetry.NoOpService{})() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) @@ -759,7 +418,7 @@ func TestNewGitClient(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1", "") + f := &cmdutil.Factory{} f.Config = func() (gh.Config, error) { if tt.config == nil { return config.NewBlankConfig(), nil @@ -767,7 +426,7 @@ func TestNewGitClient(t *testing.T) { return tt.config, nil } } - f.ExecutableName = tt.executable + f.ExecutablePath = tt.executable ios, _, _, _ := iostreams.Test() f.IOStreams = ios c := newGitClient(f) @@ -784,35 +443,3 @@ func defaultConfig() *ghmock.ConfigMock { cfg.Set("nonsense.com", "oauth_token", "BLAH") return cfg } - -func pagerConfig() gh.Config { - return config.NewFromString("pager: CONFIG_PAGER") -} - -func disablePromptConfig() gh.Config { - return config.NewFromString("prompt: disabled") -} - -func enableAccessiblePrompterConfig() gh.Config { - return config.NewFromString("accessible_prompter: enabled") -} - -func disableAccessiblePrompterConfig() gh.Config { - return config.NewFromString("accessible_prompter: disabled") -} - -func disableSpinnersConfig() gh.Config { - return config.NewFromString("spinner: disabled") -} - -func enableSpinnersConfig() gh.Config { - return config.NewFromString("spinner: enabled") -} - -func disableColorLabelsConfig() gh.Config { - return config.NewFromString("color_labels: disabled") -} - -func enableColorLabelsConfig() gh.Config { - return config.NewFromString("color_labels: enabled") -} diff --git a/pkg/cmd/search/shared/shared_test.go b/pkg/cmd/search/shared/shared_test.go index c66e0908f52..bd8060943c2 100644 --- a/pkg/cmd/search/shared/shared_test.go +++ b/pkg/cmd/search/shared/shared_test.go @@ -2,22 +2,27 @@ package shared import ( "fmt" + "net/http" "testing" "time" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" - "github.com/cli/cli/v2/pkg/cmd/factory" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/search" "github.com/stretchr/testify/assert" ) func TestSearcher(t *testing.T) { - f := factory.New("1", "") - f.Config = func() (gh.Config, error) { - return config.NewBlankConfig(), nil + f := &cmdutil.Factory{ + Config: func() (gh.Config, error) { + return config.NewBlankConfig(), nil + }, + HttpClient: func() (*http.Client, error) { + return &http.Client{}, nil + }, } _, err := Searcher(f) assert.NoError(t, err) diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index e39886ecda9..4bdfdd416b4 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -22,11 +22,11 @@ import ( ) type PreviewOptions struct { - IO *iostreams.IOStreams - HttpClient func() (*http.Client, error) - Prompter prompter.Prompter - Executable func() string - RenderFile func(string, string) string + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Prompter prompter.Prompter + ExecutablePath string + RenderFile func(string, string) string RepoArg string SkillName string @@ -38,10 +38,10 @@ type PreviewOptions struct { // NewCmdPreview creates the "skills preview" command. func NewCmdPreview(f *cmdutil.Factory, runF func(*PreviewOptions) error) *cobra.Command { opts := &PreviewOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - Prompter: f.Prompter, - Executable: f.Executable, + IO: f.IOStreams, + HttpClient: f.HttpClient, + Prompter: f.Prompter, + ExecutablePath: f.ExecutablePath, } opts.RenderFile = func(filePath, content string) string { return renderMarkdownPreview(opts.IO, filePath, content) diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 05511484eae..2542b9d904f 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -47,12 +47,12 @@ var SkillSearchFields = []string{ } type SearchOptions struct { - IO *iostreams.IOStreams - HttpClient func() (*http.Client, error) - Config func() (gh.Config, error) - Prompter prompter.Prompter - Executable string // path to the current gh binary for install subprocess - Exporter cmdutil.Exporter + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Config func() (gh.Config, error) + Prompter prompter.Prompter + ExecutablePath string // path to the current gh binary for install subprocess + Exporter cmdutil.Exporter // User inputs Query string @@ -64,11 +64,11 @@ type SearchOptions struct { // NewCmdSearch creates the "skills search" command. func NewCmdSearch(f *cmdutil.Factory, runF func(*SearchOptions) error) *cobra.Command { opts := &SearchOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - Config: f.Config, - Prompter: f.Prompter, - Executable: f.Executable(), + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Prompter: f.Prompter, + ExecutablePath: f.ExecutablePath, } cmd := &cobra.Command{ @@ -585,7 +585,7 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { } //nolint:gosec // arguments are from user-selected search results, not arbitrary input - cmd := exec.Command(opts.Executable, "skills", "install", s.Repo, installArg, + cmd := exec.Command(opts.ExecutablePath, "skills", "install", s.Repo, installArg, "--agent", host.ID, "--scope", scope) cmd.Stdin = os.Stdin cmd.Stdout = opts.IO.Out diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index f746ec8978d..9ea8ce4a682 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -2,9 +2,6 @@ package cmdutil import ( "net/http" - "os" - "path/filepath" - "strings" "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" @@ -18,7 +15,7 @@ import ( type Factory struct { AppVersion string - ExecutableName string + ExecutablePath string InvokingAgent string Browser browser.Browser @@ -27,8 +24,11 @@ type Factory struct { IOStreams *iostreams.IOStreams Prompter prompter.Prompter - BaseRepo func() (ghrepo.Interface, error) - Branch func() (string, error) + BaseRepo func() (ghrepo.Interface, error) + Branch func() (string, error) + Cfg gh.Config + // TODO: Config should be removed in favour of cfg being passed to the right place, + // but this is going to be very invasive and shouldn't be done as part of a feature change. Config func() (gh.Config, error) HttpClient func() (*http.Client, error) // PlainHttpClient is a special HTTP client that does not automatically set @@ -37,69 +37,3 @@ type Factory struct { PlainHttpClient func() (*http.Client, error) Remotes func() (context.Remotes, error) } - -// Executable is the path to the currently invoked binary -func (f *Factory) Executable() string { - ghPath := os.Getenv("GH_PATH") - if ghPath != "" { - return ghPath - } - if !strings.ContainsRune(f.ExecutableName, os.PathSeparator) { - f.ExecutableName = executable(f.ExecutableName) - } - return f.ExecutableName -} - -// Finds the location of the executable for the current process as it's found in PATH, respecting symlinks. -// If the process couldn't determine its location, return fallbackName. If the executable wasn't found in -// PATH, return the absolute location to the program. -// -// The idea is that the result of this function is callable in the future and refers to the same -// installation of gh, even across upgrades. This is needed primarily for Homebrew, which installs software -// under a location such as `/usr/local/Cellar/gh/1.13.1/bin/gh` and symlinks it from `/usr/local/bin/gh`. -// When the version is upgraded, Homebrew will often delete older versions, but keep the symlink. Because of -// this, we want to refer to the `gh` binary as `/usr/local/bin/gh` and not as its internal Homebrew -// location. -// -// None of this would be needed if we could just refer to GitHub CLI as `gh`, i.e. without using an absolute -// path. However, for some reason Homebrew does not include `/usr/local/bin` in PATH when it invokes git -// commands to update its taps. If `gh` (no path) is being used as git credential helper, as set up by `gh -// auth login`, running `brew update` will print out authentication errors as git is unable to locate -// Homebrew-installed `gh`. -func executable(fallbackName string) string { - exe, err := os.Executable() - if err != nil { - return fallbackName - } - - base := filepath.Base(exe) - path := os.Getenv("PATH") - for _, dir := range filepath.SplitList(path) { - p, err := filepath.Abs(filepath.Join(dir, base)) - if err != nil { - continue - } - f, err := os.Lstat(p) - if err != nil { - continue - } - - if p == exe { - return p - } else if f.Mode()&os.ModeSymlink != 0 { - realP, err := filepath.EvalSymlinks(p) - if err != nil { - continue - } - realExe, err := filepath.EvalSymlinks(exe) - if err != nil { - continue - } - if realP == realExe { - return p - } - } - } - - return exe -} diff --git a/pkg/cmdutil/repo_override.go b/pkg/cmdutil/repo_override.go index 791dd919a21..b037859e737 100644 --- a/pkg/cmdutil/repo_override.go +++ b/pkg/cmdutil/repo_override.go @@ -52,12 +52,12 @@ func EnableRepoOverride(cmd *cobra.Command, f *Factory) { return err } repoOverride, _ := cmd.Flags().GetString("repo") - f.BaseRepo = OverrideBaseRepoFunc(f, repoOverride) + f.BaseRepo = OverrideBaseRepoFunc(f.BaseRepo, repoOverride) return nil } } -func OverrideBaseRepoFunc(f *Factory, override string) func() (ghrepo.Interface, error) { +func OverrideBaseRepoFunc(baseRepoFunc func() (ghrepo.Interface, error), override string) func() (ghrepo.Interface, error) { if override == "" { override = os.Getenv("GH_REPO") } @@ -66,5 +66,5 @@ func OverrideBaseRepoFunc(f *Factory, override string) func() (ghrepo.Interface, return ghrepo.FromFullName(override) } } - return f.BaseRepo + return baseRepoFunc } From 17776cafc17df463501c7aeb030584d57da6650a Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 17 Apr 2026 11:45:44 +0200 Subject: [PATCH 04/29] Apply review feedback - Harden SpawnSendTelemetry against relative executable paths - Use io.Copy for telemetry subprocess stdin write - Clean up GH_TELEMETRY/DO_NOT_TRACK help text - Fall back to built-in defaults (NoOp telemetry) on config load failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/ghcmd/cmd.go | 35 ++++++++++++------- internal/telemetry/telemetry.go | 15 ++++++-- .../verify/verify_integration_test.go | 8 ++--- pkg/cmd/factory/default.go | 21 +++++------ pkg/cmd/factory/default_test.go | 2 +- pkg/cmd/root/help_topic.go | 8 ++--- pkg/cmdutil/factory.go | 10 ++++-- 7 files changed, 63 insertions(+), 36 deletions(-) diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index eab842c5a79..7350437dfb4 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -52,13 +52,18 @@ func Main() exitCode { buildVersion := build.Version hasDebug, _ := utils.IsDebugEnabled() - cfg, err := config.NewConfig() - if err != nil { - fmt.Fprintf(os.Stderr, "failed to load config: %s\n", err) - return exitError + cfg, cfgErr := config.NewConfig() + if cfgErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to load config: %s\n", cfgErr) } + cfgFunc := func() (gh.Config, error) { return cfg, cfgErr } - ioStreams := newIOStreams(cfg) + var ioStreams *iostreams.IOStreams + if cfgErr == nil { + ioStreams = newIOStreams(cfg) + } else { + ioStreams = iostreams.System() + } stderr := ioStreams.ErrOut ghExecutablePath := executablePath("gh") @@ -70,9 +75,13 @@ func Main() exitCode { } var telemetryService ghtelemetry.Service - if os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg) { + switch { + case cfgErr != nil: + // Without a valid on-disk config we can't honour user telemetry preferences, so disable it to be safe. telemetryService = &telemetry.NoOpService{} - } else { + case os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg): + telemetryService = &telemetry.NoOpService{} + default: telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) switch telemetryState { case telemetry.Disabled: @@ -100,12 +109,14 @@ func Main() exitCode { } defer telemetryService.Flush() - cmdFactory := factory.New(buildVersion, string(agents.Detect()), cfg, ioStreams, ghExecutablePath, telemetryService) + cmdFactory := factory.New(buildVersion, string(agents.Detect()), cfgFunc, ioStreams, ghExecutablePath, telemetryService) - var m migration.MultiAccount - if err := cfg.Migrate(m); err != nil { - fmt.Fprintln(stderr, err) - return exitError + if cfgErr == nil { + var m migration.MultiAccount + if err := cfg.Migrate(m); err != nil { + fmt.Fprintln(stderr, err) + return exitError + } } ctx := context.Background() diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index b046ec77d84..67fb8b7626e 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -354,6 +354,13 @@ func SpawnSendTelemetry(executable string, payload SendTelemetryPayload) { return } + // Resolve the executable to an absolute path before changing the child's + // working directory. Without this, a relative path (e.g. from GH_PATH) would + // be resolved against cmd.Dir at Start time and fail to spawn. + if abs, err := filepath.Abs(executable); err == nil { + executable = abs + } + cmd := exec.Command(executable, "send-telemetry") cmd.Stdout = io.Discard @@ -362,7 +369,10 @@ func SpawnSendTelemetry(executable string, payload SendTelemetryPayload) { // Set the working directory to a stable directory elsewhere so that the subprocess doesn't // hold a reference to the parent's current working directory, avoiding any weirdness around // deleting the parent process's current working directory while the child is still running. - cmd.Dir = os.TempDir() + // Only do this when we have an absolute executable path so that the child can still be found. + if filepath.IsAbs(executable) { + cmd.Dir = os.TempDir() + } // Configure the child process to be detached from the parent so that it can continue running // after the parent exits, and so that it doesn't receive any signals sent to the parent. @@ -381,7 +391,8 @@ func SpawnSendTelemetry(executable string, payload SendTelemetryPayload) { // Write the payload synchronously into the kernel pipe buffer, then close // the pipe to signal EOF. The child reads the complete payload from stdin. - _, _ = stdin.Write(payloadBytes) + // io.Copy loops until all bytes are written, avoiding any risk of a short write. + _, _ = io.Copy(stdin, bytes.NewReader(payloadBytes)) _ = stdin.Close() // Release resources associated with the child process since we will never Wait for it. diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index 10a1e521657..b9994141313 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -30,7 +30,7 @@ func TestVerifyIntegration(t *testing.T) { ios, _, _, _ := iostreams.Test() hc, err := factory.HttpClientFunc( - &config.AuthConfig{}, + func() (gh.Config, error) { return config.NewBlankConfig(), nil }, ios, "test", "", @@ -150,7 +150,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { ios, _, _, _ := iostreams.Test() hc, err := factory.HttpClientFunc( - &config.AuthConfig{}, + func() (gh.Config, error) { return config.NewBlankConfig(), nil }, ios, "test", "", @@ -228,7 +228,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { cfg := config.NewBlankConfig() ios, _, _, _ := iostreams.Test() hc, err := factory.HttpClientFunc( - cfg.Authentication(), + func() (gh.Config, error) { return cfg, nil }, ios, "test", "", @@ -325,7 +325,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { cfg := config.NewBlankConfig() ios, _, _, _ := iostreams.Test() hc, err := factory.HttpClientFunc( - cfg.Authentication(), + func() (gh.Config, error) { return cfg, nil }, ios, "test", "", diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index bf203bd4314..f61e51b452f 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -23,19 +23,16 @@ import ( var ssoHeader string var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`) -func New(appVersion string, invokingAgent string, cfg gh.Config, ios *iostreams.IOStreams, executablePath string, telemetryDisabler ghtelemetry.Disabler) *cmdutil.Factory { +func New(appVersion string, invokingAgent string, cfgFunc func() (gh.Config, error), ios *iostreams.IOStreams, executablePath string, telemetryDisabler ghtelemetry.Disabler) *cmdutil.Factory { f := &cmdutil.Factory{ - AppVersion: appVersion, - InvokingAgent: invokingAgent, - Cfg: cfg, - Config: func() (gh.Config, error) { - return cfg, nil - }, // No factory dependencies + AppVersion: appVersion, + InvokingAgent: invokingAgent, + Config: cfgFunc, ExecutablePath: executablePath, } f.IOStreams = ios - f.HttpClient = HttpClientFunc(cfg.Authentication(), ios, appVersion, invokingAgent, telemetryDisabler) + f.HttpClient = HttpClientFunc(cfgFunc, ios, appVersion, invokingAgent, telemetryDisabler) f.PlainHttpClient = plainHttpClientFunc(ios, appVersion, invokingAgent, telemetryDisabler) f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable f.Remotes = remotesFunc(f) // Depends on Config, and GitClient @@ -187,10 +184,14 @@ func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) { return rr.Resolver() } -func HttpClientFunc(authCfg gh.AuthConfig, ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) func() (*http.Client, error) { +func HttpClientFunc(cfgFunc func() (gh.Config, error), ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) func() (*http.Client, error) { return func() (*http.Client, error) { + cfg, err := cfgFunc() + if err != nil { + return nil, err + } opts := api.HTTPClientOptions{ - Config: authCfg, + Config: cfg.Authentication(), Log: ios.ErrOut, LogColorize: ios.ColorEnabled(), AppVersion: appVersion, diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index 6f376e48c88..9cf34f3b0e5 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -353,7 +353,7 @@ func TestSSOURL(t *testing.T) { t.Run(tt.name, func(t *testing.T) { cfg := config.NewBlankConfig() ios, _, _, stderr := iostreams.Test() - client, err := HttpClientFunc(cfg.Authentication(), ios, "v1.2.3", "", &telemetry.NoOpService{})() + client, err := HttpClientFunc(func() (gh.Config, error) { return cfg, nil }, ios, "v1.2.3", "", &telemetry.NoOpService{})() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) if tt.sso != "" { diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index fbacef356cc..3002a351294 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -118,10 +118,10 @@ var HelpTopics = []helpTopic{ more compatible with speech synthesis and braille screen readers. %[1]sGH_TELEMETRY%[1]s: set to %[1]slog%[1]s to print telemetry data to standard error instead of sending it. - Set to %[1]sfalse%[1]s or %[1]s0%[1]s to disable telemetry that would have been printed when set to %[1]slog%[1]s. - - %[1]sDO_NOT_TRACK%[1]s: set to %[1]strue%[1]s or %[1]s1%[1]s to disable telemetry that would have been printed - when %[1]sGH_TELEMETRY%[1]s is set to %[1]slog%[1]s. %[1]sGH_TELEMETRY%[1]s takes precedence if both are set. + Set to %[1]sfalse%[1]s or %[1]s0%[1]s to disable telemetry. Takes precedence over %[1]sDO_NOT_TRACK%[1]s. + + %[1]sDO_NOT_TRACK%[1]s: set to %[1]strue%[1]s or %[1]s1%[1]s to disable telemetry. Ignored when + %[1]sGH_TELEMETRY%[1]s is set, which takes precedence. %[1]sGH_SPINNER_DISABLED%[1]s: set to a truthy value to replace the spinner animation with a textual progress indicator. diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index 9ea8ce4a682..1faf859f00c 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -26,9 +26,13 @@ type Factory struct { BaseRepo func() (ghrepo.Interface, error) Branch func() (string, error) - Cfg gh.Config - // TODO: Config should be removed in favour of cfg being passed to the right place, - // but this is going to be very invasive and shouldn't be done as part of a feature change. + // It would be nice if Config were just loaded once at startup and an error + // were returned, but this would prevent commands like "gh version" from running. + // So for now, we eagerly load the config and don't fail if there is an error, + // and defer the error handling to commands that need it. + // HOWEVER, as an additional point, the root command setup currently DOES call + // this and errors, so we never get to "gh version" anyway. + // We need to revisit that, but I don't want to make it worse. Config func() (gh.Config, error) HttpClient func() (*http.Client, error) // PlainHttpClient is a special HTTP client that does not automatically set From 6709e315df8fac55140e7e281cd6e20262cff906 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 16 Apr 2026 22:12:01 +0200 Subject: [PATCH 05/29] Do not send telemetry for aliases --- .../telemetry/no-telemetry-for-alias.txtar | 18 +++++++++++++ pkg/cmd/root/alias.go | 27 ++++++++++++------- 2 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 acceptance/testdata/telemetry/no-telemetry-for-alias.txtar diff --git a/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar b/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar new file mode 100644 index 00000000000..733bea11f5c --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar @@ -0,0 +1,18 @@ +# Aliases should not leak their user-defined names via telemetry, but the +# resolved inner command should still record normally — its path is a core +# gh command and conveys no user-authored identifier. + +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +# Create a regular (non-shell) alias that resolves to an existing command. +exec gh alias set secret-project-alias version + +# Invoking the alias must not produce any event carrying the alias name. +exec gh secret-project-alias +! stderr 'secret-project-alias' + +# The resolved inner command still records telemetry as normal. +stderr 'Telemetry payload:' +stderr '"command": "gh version"' diff --git a/pkg/cmd/root/alias.go b/pkg/cmd/root/alias.go index 4f504f2b8c4..ea4c21d8a97 100644 --- a/pkg/cmd/root/alias.go +++ b/pkg/cmd/root/alias.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/findsh" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" @@ -17,7 +18,7 @@ import ( ) func NewCmdShellAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: aliasName, Short: fmt.Sprintf("Shell alias for %q", text.Truncate(80, aliasValue)), RunE: func(c *cobra.Command, args []string) error { @@ -39,16 +40,19 @@ func NewCmdShellAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *co } return nil }, - GroupID: "alias", - Annotations: map[string]string{ - "skipAuthCheck": "true", - }, + GroupID: "alias", DisableFlagParsing: true, } + cmdutil.DisableAuthCheck(cmd) + // Aliases are user-defined names and must not be reported as telemetry + // dimensions, since the name itself may be sensitive (e.g. project or + // organization names). + cmdutil.DisableTelemetry(cmd) + return cmd } func NewCmdAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: aliasName, Short: fmt.Sprintf("Alias for %q", text.Truncate(80, aliasValue)), RunE: func(c *cobra.Command, args []string) error { @@ -60,12 +64,15 @@ func NewCmdAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.C root.SetArgs(expandedArgs) return root.Execute() }, - GroupID: "alias", - Annotations: map[string]string{ - "skipAuthCheck": "true", - }, + GroupID: "alias", DisableFlagParsing: true, } + cmdutil.DisableAuthCheck(cmd) + // Aliases are user-defined names and must not be reported as telemetry + // dimensions, since the name itself may be sensitive (e.g. project or + // organization names). + cmdutil.DisableTelemetry(cmd) + return cmd } // ExpandAlias processes argv to see if it should be rewritten according to a user's aliases. From 998b6212b38f653f997c79fc42e6f13197008581 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 17 Apr 2026 19:58:59 +0200 Subject: [PATCH 06/29] Add skills specific telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add skills specific telemetry * Remove VisibilityFuture, inline goroutine at call sites The VisibilityFuture/FetchRepoVisibilityAsync/Wait wrapper was an unidiomatic async abstraction built for a single pattern used in exactly two call sites. In Go the channel is already the future; wrapping it in a struct with a Wait(timeout) method adds no value. Delete the abstraction and inline a local visResult struct, buffered channel, goroutine, and select at each call site. Behavior is preserved exactly: err -> "unknown", timeout -> "unknown", success+public -> include skill_names. FetchRepoVisibility (synchronous) is kept as-is. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix nonsense copilot tests * Update telemetry tests for public-only dims and search event removal Production telemetry emission changed: - preview: skill_owner/skill_repo/skill_name (renamed from skill_names) are now emitted only when repo_visibility=public. - install: skill_owner/skill_repo/skill_names are now emitted only when repo_visibility=public. - search: the initial skill_search event was removed entirely; the skill_search_install event no longer carries query/owner dims. Update tests to match: rename skill_names -> skill_name in preview, make owner/repo assertions conditional on public visibility in both preview and install, and reduce the search test to a single event with explicit Empty assertions for the removed query/owner dims so a privacy regression cannot pass silently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Test CategorizeHost and switch telemetry to skill_host_type Add TestCategorizeHost covering all four classification branches (github.com, ghes, tenancy, uncategorized) with cases verified against the real ghauth implementation rather than guessed. Update install and preview unit tests to assert the new skill_host_type dimension name, and fix a typo in the preview acceptance txtar (skill_hos_type -> skill_host_type). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Shrink visibility wait and test unknown visibility The 2s visibilityWaitTimeout was wildly overprovisioned: by the time telemetry emission reaches the select, the command has already done several serial GitHub REST calls (and for install, a git sparse-checkout plus possibly interactive prompts), so the one-call visibility fetch has almost always completed. Drop the timeout to 200ms — a short safety net for a stalled REST call, not a wait budget for a healthy one. Also adds a table-driven case to TestFetchRepoVisibility covering an unknown/future visibility value from the API, addressing @babakks' review nitpick. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../testdata/skills/skills-install.txtar | 13 + .../testdata/skills/skills-preview.txtar | 12 + .../skills/skills-publish-dry-run.txtar | 4 - .../testdata/skills/skills-search-page.txtar | 2 +- .../testdata/skills/skills-search.txtar | 4 +- internal/ghinstance/host.go | 16 ++ internal/ghinstance/host_test.go | 54 +++++ internal/skills/discovery/discovery.go | 69 ++++-- internal/skills/discovery/discovery_test.go | 80 +++++++ internal/telemetry/fake.go | 20 ++ pkg/cmd/root/root.go | 2 +- pkg/cmd/skills/install/install.go | 68 +++++- pkg/cmd/skills/install/install_test.go | 188 ++++++++++++++- pkg/cmd/skills/preview/preview.go | 67 +++++- pkg/cmd/skills/preview/preview_test.go | 223 +++++++++++++++++- pkg/cmd/skills/search/search.go | 12 +- pkg/cmd/skills/search/search_test.go | 77 +++++- pkg/cmd/skills/skills.go | 13 +- pkg/cmd/skills/skills_test.go | 19 ++ 19 files changed, 895 insertions(+), 48 deletions(-) create mode 100644 pkg/cmd/skills/skills_test.go diff --git a/acceptance/testdata/skills/skills-install.txtar b/acceptance/testdata/skills/skills-install.txtar index c365cb83389..0311a0db280 100644 --- a/acceptance/testdata/skills/skills-install.txtar +++ b/acceptance/testdata/skills/skills-install.txtar @@ -18,3 +18,16 @@ stdout 'Installed git-commit' # Verify the skill was written to the custom directory exists $WORK/custom-skills/git-commit/SKILL.md grep 'github-repo' $WORK/custom-skills/git-commit/SKILL.md + +# Telemetry: skill_install event records agent hosts, repo identifiers, +# and (for a public repo) the installed skill name. +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot +stderr 'Telemetry payload:' +stderr '"type": "skill_install"' +stderr '"agent_hosts": "github-copilot"' +stderr '"skill_host_type": "github.com"' +stderr '"skill_owner": "github"' +stderr '"skill_repo": "awesome-copilot"' diff --git a/acceptance/testdata/skills/skills-preview.txtar b/acceptance/testdata/skills/skills-preview.txtar index be1be5244d7..af1d0bbbe2c 100644 --- a/acceptance/testdata/skills/skills-preview.txtar +++ b/acceptance/testdata/skills/skills-preview.txtar @@ -7,3 +7,15 @@ stdout 'git-commit/' # Preview a skill that doesn't exist should error ! exec gh skill preview github/awesome-copilot nonexistent-skill-xyz stderr 'not found' + +# Telemetry: skill_preview event records repo identifiers and, for a +# public repo, the skill name. +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +exec gh skill preview github/awesome-copilot git-commit +stderr 'Telemetry payload:' +stderr '"type": "skill_preview"' +stderr '"skill_host_type": "github.com"' +stderr '"skill_owner": "github"' +stderr '"skill_repo": "awesome-copilot"' diff --git a/acceptance/testdata/skills/skills-publish-dry-run.txtar b/acceptance/testdata/skills/skills-publish-dry-run.txtar index cb32fa7e26e..786204951e1 100644 --- a/acceptance/testdata/skills/skills-publish-dry-run.txtar +++ b/acceptance/testdata/skills/skills-publish-dry-run.txtar @@ -6,10 +6,6 @@ stderr 'no skills found in' exec gh skill publish --dry-run $WORK/test-repo stdout 'hello-world' -# Validate alias should work identically -exec gh skill validate --dry-run $WORK/test-repo -stdout 'hello-world' - # Publish dry-run with --tag exec gh skill publish --dry-run --tag v1.0.0 $WORK/test-repo stdout 'hello-world' diff --git a/acceptance/testdata/skills/skills-search-page.txtar b/acceptance/testdata/skills/skills-search-page.txtar index 30c044f78cf..48409c2354d 100644 --- a/acceptance/testdata/skills/skills-search-page.txtar +++ b/acceptance/testdata/skills/skills-search-page.txtar @@ -1,3 +1,3 @@ # Pagination returns results on page 2 -exec gh skill search copilot --page 2 +exec gh skill search --owner github copilot --page 2 stdout 'copilot' diff --git a/acceptance/testdata/skills/skills-search.txtar b/acceptance/testdata/skills/skills-search.txtar index 5e8c7744208..e16936b0d1b 100644 --- a/acceptance/testdata/skills/skills-search.txtar +++ b/acceptance/testdata/skills/skills-search.txtar @@ -1,5 +1,5 @@ # Search for skills matching a query -exec gh skill search copilot +exec gh skill search --owner github copilot stdout 'copilot' # Search with JSON output @@ -9,4 +9,4 @@ stdout '"repo"' # Search with a short query should error ! exec gh skill search a -stderr 'at least' +stderr 'at least' \ No newline at end of file diff --git a/internal/ghinstance/host.go b/internal/ghinstance/host.go index 7abfd83acaf..dfea6dd946b 100644 --- a/internal/ghinstance/host.go +++ b/internal/ghinstance/host.go @@ -96,3 +96,19 @@ func HostPrefix(hostname string) string { } return fmt.Sprintf("https://%s/", hostname) } + +func CategorizeHost(host string) string { + if host == defaultHostname { + return "github.com" + } + + if ghauth.IsEnterprise(host) { + return "ghes" + } + + if ghauth.IsTenancy(host) { + return "tenancy" + } + + return "uncategorized" +} diff --git a/internal/ghinstance/host_test.go b/internal/ghinstance/host_test.go index 1b7e0146d04..c4f447780a9 100644 --- a/internal/ghinstance/host_test.go +++ b/internal/ghinstance/host_test.go @@ -157,3 +157,57 @@ func TestRESTPrefix(t *testing.T) { }) } } + +func TestCategorizeHost(t *testing.T) { + tests := []struct { + name string + host string + want string + }{ + { + name: "github.com returns github.com", + host: "github.com", + want: "github.com", + }, + { + name: "classic GHES hostname returns ghes", + host: "ghe.io", + want: "ghes", + }, + { + name: "arbitrary enterprise hostname returns ghes", + host: "enterprise.example.com", + want: "ghes", + }, + { + name: "tenant subdomain of ghe.com returns tenancy", + host: "tenant.ghe.com", + want: "tenancy", + }, + { + name: "api subdomain under tenant returns tenancy", + host: "api.tenant.ghe.com", + want: "tenancy", + }, + { + name: "bare ghe.com returns ghes", + host: "ghe.com", + want: "ghes", + }, + { + name: "github.localhost returns uncategorized", + host: "github.localhost", + want: "uncategorized", + }, + { + name: "github.com subdomain returns uncategorized", + host: "garage.github.com", + want: "uncategorized", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, CategorizeHost(tt.host)) + }) + } +} diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 93de67faac5..2d6c1ee7256 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -126,18 +126,37 @@ type treeResponse struct { Truncated bool `json:"truncated"` } -type blobResponse struct { - SHA string `json:"sha"` - Content string `json:"content"` - Encoding string `json:"encoding"` -} +type RepoVisibility string + +const ( + RepoVisibilityPublic RepoVisibility = "public" + RepoVisibilityPrivate RepoVisibility = "private" + RepoVisibilityInternal RepoVisibility = "internal" +) -type releaseResponse struct { - TagName string `json:"tag_name"` +func parseRepoVisibility(s string) (RepoVisibility, error) { + switch s { + case "public": + return RepoVisibilityPublic, nil + case "private": + return RepoVisibilityPrivate, nil + case "internal": + return RepoVisibilityInternal, nil + default: + return "", fmt.Errorf("unknown repository visibility: %q", s) + } } -type repoResponse struct { - DefaultBranch string `json:"default_branch"` +// FetchRepoVisibility returns the repository visibility: "public", "private", or "internal". +func FetchRepoVisibility(client *api.Client, host, owner, repo string) (RepoVisibility, error) { + apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) + var resp struct { + Visibility string `json:"visibility"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return "", err + } + return parseRepoVisibility(resp.Visibility) } // ResolveRef determines the git ref to use for a given owner/repo. @@ -266,8 +285,10 @@ func (e *noReleasesError) Error() string { return e.reason } func resolveLatestRelease(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", url.PathEscape(owner), url.PathEscape(repo)) - var release releaseResponse - if err := client.REST(host, "GET", apiPath, nil, &release); err != nil { + var resp struct { + TagName string `json:"tag_name"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { // A 404 means the repository has no releases. This is the // only case where falling back to the default branch is safe. // Any other HTTP error (403, 500, …) or network failure is @@ -278,19 +299,21 @@ func resolveLatestRelease(client *api.Client, host, owner, repo string) (*Resolv } return nil, fmt.Errorf("could not fetch latest release: %w", err) } - if release.TagName == "" { + if resp.TagName == "" { return nil, &noReleasesError{reason: "latest release has no tag"} } - return resolveTagRef(client, host, owner, repo, release.TagName) + return resolveTagRef(client, host, owner, repo, resp.TagName) } func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) - var repoResp repoResponse - if err := client.REST(host, "GET", apiPath, nil, &repoResp); err != nil { + var resp struct { + DefaultBranch string `json:"default_branch"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { return nil, fmt.Errorf("could not determine default branch: %w", err) } - branch := repoResp.DefaultBranch + branch := resp.DefaultBranch if branch == "" { return nil, fmt.Errorf("could not determine default branch for %s/%s", owner, repo) } @@ -657,18 +680,22 @@ func walkTree(client *api.Client, host, owner, repo, sha, prefix string, depth i // FetchBlob retrieves the content of a blob by SHA. func FetchBlob(client *api.Client, host, owner, repo, sha string) (string, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/blobs/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) - var blob blobResponse - if err := client.REST(host, "GET", apiPath, nil, &blob); err != nil { + var resp struct { + SHA string `json:"sha"` + Content string `json:"content"` + Encoding string `json:"encoding"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { return "", fmt.Errorf("could not fetch blob: %w", err) } - if blob.Encoding != "base64" { - return "", fmt.Errorf("unexpected blob encoding: %s", blob.Encoding) + if resp.Encoding != "base64" { + return "", fmt.Errorf("unexpected blob encoding: %s", resp.Encoding) } // GitHub API returns base64 with embedded newlines; use the StdEncoding // decoder via a reader to handle them transparently. - decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(blob.Content))) + decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(resp.Content))) if err != nil { return "", fmt.Errorf("could not decode blob content: %w", err) } diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index f9abc593cf8..8929e17871b 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -561,6 +561,86 @@ func TestFetchBlob(t *testing.T) { } } +func TestFetchRepoVisibility(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + want RepoVisibility + wantErr string + }{ + { + name: "public repo", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "public", + })) + }, + want: RepoVisibilityPublic, + }, + { + name: "private repo", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "private", + })) + }, + want: RepoVisibilityPrivate, + }, + { + name: "internal repo", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "internal", + })) + }, + want: RepoVisibilityInternal, + }, + { + name: "unknown visibility", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "cool-visibility", + })) + }, + wantErr: `unknown repository visibility: "cool-visibility"`, + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "HTTP 500", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + got, err := FetchRepoVisibility(client, "github.com", "monalisa", "octocat-skills") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + func TestDiscoverSkills(t *testing.T) { tests := []struct { name string diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go index 1dc45ab2692..4eb22e898a5 100644 --- a/internal/telemetry/fake.go +++ b/internal/telemetry/fake.go @@ -13,3 +13,23 @@ func (r *EventRecorderSpy) Record(event ghtelemetry.Event) { func (r *EventRecorderSpy) Disable() {} func (r *EventRecorderSpy) Flush() {} + +// CommandRecorderSpy is a test double for ghtelemetry.CommandRecorder. +// It captures recorded events and the most recent SetSampleRate call so tests can +// assert on the sampling behavior commands attempt to configure. +type CommandRecorderSpy struct { + Events []ghtelemetry.Event + LastSampleRate int +} + +func (r *CommandRecorderSpy) Record(event ghtelemetry.Event) { + r.Events = append(r.Events, event) +} + +func (r *CommandRecorderSpy) Disable() {} + +func (r *CommandRecorderSpy) SetSampleRate(rate int) { + r.LastSampleRate = rate +} + +func (r *CommandRecorderSpy) Flush() {} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 7df9d2986ea..9f4fa6f5bdc 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -149,7 +149,7 @@ func NewCmdRoot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, versi cmd.AddCommand(codespaceCmd.NewCmdCodespace(f)) cmd.AddCommand(projectCmd.NewCmdProject(f)) cmd.AddCommand(previewCmd.NewCmdPreview(f)) - cmd.AddCommand(skillsCmd.NewCmdSkills(f)) + cmd.AddCommand(skillsCmd.NewCmdSkills(f, telemetry)) // Root commands with standalone functionality and no subcommands cmd.AddCommand(copilotCmd.NewCmdCopilot(f, nil)) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 1b6a7fd8fb4..d4a05440e7b 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -8,11 +8,14 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" @@ -38,6 +41,7 @@ const ( // InstallOptions holds all dependencies and user-provided flags for the install command. type InstallOptions struct { IO *iostreams.IOStreams + Telemetry ghtelemetry.EventRecorder HttpClient func() (*http.Client, error) Prompter prompter.Prompter GitClient *git.Client @@ -59,9 +63,10 @@ type InstallOptions struct { } // NewCmdInstall creates the "skills install" command. -func NewCmdInstall(f *cmdutil.Factory, runF func(*InstallOptions) error) *cobra.Command { +func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*InstallOptions) error) *cobra.Command { opts := &InstallOptions{ IO: f.IOStreams, + Telemetry: telemetry, Prompter: f.Prompter, GitClient: f.GitClient, Remotes: f.Remotes, @@ -232,6 +237,19 @@ func installRun(opts *InstallOptions) error { return err } + // Kick off the visibility fetch in parallel with the install work so + // the extra API roundtrip doesn't add latency on the critical path. + // The result is consumed when the telemetry event is emitted below. + type visResult struct { + vis discovery.RepoVisibility + err error + } + visCh := make(chan visResult, 1) + go func() { + vis, err := discovery.FetchRepoVisibility(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName()) + visCh <- visResult{vis: vis, err: err} + }() + resolved, err := resolveVersion(opts, apiClient, hostname) if err != nil { return err @@ -325,9 +343,57 @@ func installRun(opts *InstallOptions) error { } } + dims := map[string]string{ + "agent_hosts": mapAgentHostsToIDs(selectedHosts), + "skill_host_type": ghinstance.CategorizeHost(opts.repo.RepoHost()), + } + select { + case r := <-visCh: + if r.err == nil { + dims["repo_visibility"] = string(r.vis) + if r.vis == discovery.RepoVisibilityPublic { + dims["skill_owner"] = opts.repo.RepoOwner() + dims["skill_repo"] = opts.repo.RepoName() + dims["skill_names"] = mapSkillsToNames(selectedSkills) + } + } else { + dims["repo_visibility"] = "unknown" + } + case <-time.After(visibilityWaitTimeout): + dims["repo_visibility"] = "unknown" + } + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_install", + Dimensions: dims, + }) + return nil } +// visibilityWaitTimeout is how long to wait at telemetry-emit time for +// the in-flight repo visibility fetch before giving up and emitting +// repo_visibility="unknown". By this point the command has already done +// several serial API calls and (for install) a git sparse-checkout, so +// the fetch has almost always completed; this budget is a short safety +// net for the case where that single REST call has stalled. +const visibilityWaitTimeout = 200 * time.Millisecond + +func mapSkillsToNames(skills []discovery.Skill) string { + names := make([]string, len(skills)) + for i, s := range skills { + names[i] = s.DisplayName() + } + return strings.Join(names, ",") +} + +func mapAgentHostsToIDs(hosts []*registry.AgentHost) string { + agentHostIDs := make([]string, len(hosts)) + for i, h := range hosts { + agentHostIDs[i] = h.ID + } + return strings.Join(agentHostIDs, ",") +} + // runLocalInstall handles installation from a local directory path. func runLocalInstall(opts *InstallOptions) error { cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 4812275247f..0560625f7fc 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -16,6 +16,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -128,7 +129,7 @@ func TestNewCmdInstall(t *testing.T) { } var gotOpts *InstallOptions - cmd := NewCmdInstall(f, func(opts *InstallOptions) error { + cmd := NewCmdInstall(f, &telemetry.NoOpService{}, func(opts *InstallOptions) error { gotOpts = opts return nil }) @@ -167,7 +168,7 @@ func TestNewCmdInstall(t *testing.T) { t.Run("command metadata", func(t *testing.T) { ios, _, _, _ := iostreams.Test() f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - cmd := NewCmdInstall(f, nil) + cmd := NewCmdInstall(f, &telemetry.NoOpService{}, nil) assert.Equal(t, "install [] [flags]", cmd.Use) assert.NotEmpty(t, cmd.Short) @@ -1348,6 +1349,9 @@ func TestInstallRun(t *testing.T) { ios.SetStdinTTY(tt.isTTY) ios.SetStderrTTY(tt.isTTY) opts := tt.opts(ios, reg) + if opts.Telemetry == nil { + opts.Telemetry = &telemetry.NoOpService{} + } err := installRun(opts) @@ -1414,6 +1418,7 @@ func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { SkillSource: "monalisa/octocat-skills", SkillName: "git-commit", Force: true, + Telemetry: &telemetry.NoOpService{}, }) require.NoError(t, err) assert.Equal(t, 1, strings.Count(stdout.String(), "Installed git-commit")) @@ -1980,3 +1985,182 @@ func Test_selectSkillsWithSelector_noDisclaimer(t *testing.T) { require.NoError(t, err) assert.NotContains(t, stderr.String(), "not verified by GitHub") } + +func TestInstallRun_TelemetryVisibility(t *testing.T) { + tests := []struct { + name string + visibility string + visibilityErr bool + wantSkillNames string + }{ + { + name: "public repo includes skill names", + visibility: "public", + wantSkillNames: "git-commit", + }, + { + name: "private repo excludes skill names", + visibility: "private", + }, + { + name: "internal repo excludes skill names", + visibility: "internal", + }, + { + name: "API error omits visibility and skill names", + visibilityErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "treeSHA", "blobSHA", gitCommitContent) + if tt.visibilityErr { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.StatusStringResponse(500, "server error"), + ) + } else { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": tt.visibility, + }), + ) + } + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + recorder := &telemetry.EventRecorderSpy{} + + err := installRun(&InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + Force: true, + Telemetry: recorder, + }) + require.NoError(t, err) + + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "skill_install", event.Type) + assert.NotEmpty(t, event.Dimensions["agent_hosts"], "agent_hosts should always be present") + + // skill_host_type is always recorded (categorized, no raw hostname for enterprise/tenancy). + assert.Equal(t, "github.com", event.Dimensions["skill_host_type"]) + + if tt.visibilityErr { + assert.Equal(t, "unknown", event.Dimensions["repo_visibility"], + "visibility fetch errors should emit repo_visibility=\"unknown\" so the fallback is distinguishable from a successful fetch") + } else { + assert.Equal(t, tt.visibility, event.Dimensions["repo_visibility"]) + } + + // Owner, repo, and skill names are only included when the repo + // is public; for private/internal/unknown they are omitted to + // avoid leaking identifiers of non-public repositories. + if tt.wantSkillNames != "" { + assert.Equal(t, "monalisa", event.Dimensions["skill_owner"]) + assert.Equal(t, "octocat-skills", event.Dimensions["skill_repo"]) + assert.Equal(t, tt.wantSkillNames, event.Dimensions["skill_names"]) + } else { + assert.Empty(t, event.Dimensions["skill_owner"]) + assert.Empty(t, event.Dimensions["skill_repo"]) + assert.Empty(t, event.Dimensions["skill_names"]) + } + }) + } +} + +func TestInstallRun_TelemetryMultipleSkills(t *testing.T) { + codeReviewContent := heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/git-commit", "type": "tree", "sha": "treeGC"}, ` + + `{"path": "skills/git-commit/SKILL.md", "type": "blob", "sha": "blobGC"}, ` + + `{"path": "skills/code-review", "type": "tree", "sha": "treeCR"}, ` + + `{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blobCR"}` + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", treeJSON) + + // Blob stubs for FetchDescriptionsConcurrent during interactive selection + encGC := base64.StdEncoding.EncodeToString([]byte(gitCommitContent)) + encCR := base64.StdEncoding.EncodeToString([]byte(codeReviewContent)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blobGC"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobGC", "content": %q, "encoding": "base64"}`, encGC))) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blobCR"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobCR", "content": %q, "encoding": "base64"}`, encCR))) + + stubInstallFiles(reg, "monalisa", "octocat-skills", "treeGC", "blobGC", gitCommitContent) + stubInstallFiles(reg, "monalisa", "octocat-skills", "treeCR", "blobCR", codeReviewContent) + + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "public", + }), + ) + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } + + recorder := &telemetry.EventRecorderSpy{} + + err := installRun(&InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: pm, + SkillSource: "monalisa/octocat-skills", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + Telemetry: recorder, + }) + require.NoError(t, err) + + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "skill_install", event.Type) + assert.Equal(t, "public", event.Dimensions["repo_visibility"]) + + // Verify comma-separated skill names (alphabetical order from DiscoverSkills) + names := strings.Split(event.Dimensions["skill_names"], ",") + assert.Len(t, names, 2) + assert.Contains(t, names, "code-review") + assert.Contains(t, names, "git-commit") +} diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 4bdfdd416b4..e9f1e0442ce 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -7,9 +7,12 @@ import ( "path" "sort" "strings" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" @@ -23,6 +26,7 @@ import ( type PreviewOptions struct { IO *iostreams.IOStreams + Telemetry ghtelemetry.EventRecorder HttpClient func() (*http.Client, error) Prompter prompter.Prompter ExecutablePath string @@ -36,9 +40,10 @@ type PreviewOptions struct { } // NewCmdPreview creates the "skills preview" command. -func NewCmdPreview(f *cmdutil.Factory, runF func(*PreviewOptions) error) *cobra.Command { +func NewCmdPreview(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*PreviewOptions) error) *cobra.Command { opts := &PreviewOptions{ IO: f.IOStreams, + Telemetry: telemetry, HttpClient: f.HttpClient, Prompter: f.Prompter, ExecutablePath: f.ExecutablePath, @@ -125,6 +130,19 @@ func previewRun(opts *PreviewOptions) error { } apiClient := api.NewClientFromHTTP(httpClient) + // Kick off the visibility fetch in parallel with the preview work so + // the extra API roundtrip doesn't add latency on the critical path. + // The result is consumed when the telemetry event is emitted below. + type visResult struct { + vis discovery.RepoVisibility + err error + } + visCh := make(chan visResult, 1) + go func() { + vis, err := discovery.FetchRepoVisibility(apiClient, hostname, owner, repoName) + visCh <- visResult{vis: vis, err: err} + }() + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Resolving %s/%s", owner, repoName)) resolved, err := discovery.ResolveRef(apiClient, hostname, owner, repoName, opts.Version) opts.IO.StopProgressIndicator() @@ -177,17 +195,50 @@ func previewRun(opts *PreviewOptions) error { // Non-interactive or skill has only SKILL.md: dump through pager if !canPrompt || len(extraFiles) == 0 { - return renderAllFiles(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) + renderAllFiles(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) + } else { + // Interactive with multiple files: show tree, then file picker + renderInteractive(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) } - // Interactive with multiple files: show tree, then file picker - return renderInteractive(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) + dims := map[string]string{ + "skill_host_type": ghinstance.CategorizeHost(opts.repo.RepoHost()), + } + select { + case r := <-visCh: + if r.err == nil { + dims["repo_visibility"] = string(r.vis) + if r.vis == discovery.RepoVisibilityPublic { + dims["skill_owner"] = opts.repo.RepoOwner() + dims["skill_repo"] = opts.repo.RepoName() + dims["skill_name"] = skill.DisplayName() + } + } else { + dims["repo_visibility"] = "unknown" + } + case <-time.After(visibilityWaitTimeout): + dims["repo_visibility"] = "unknown" + } + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_preview", + Dimensions: dims, + }) + + return nil } +// visibilityWaitTimeout is how long to wait at telemetry-emit time for +// the in-flight repo visibility fetch before giving up and emitting +// repo_visibility="unknown". By this point the command has already done +// several serial API calls and rendering work, so the fetch has almost +// always completed; this budget is a short safety net for the case +// where that single REST call has stalled. +const visibilityWaitTimeout = 200 * time.Millisecond + // renderAllFiles dumps the tree, SKILL.md, and all extra files through the pager. func renderAllFiles(opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, files []discovery.SkillFile, rendered string, extraFiles []discovery.SkillFile, - apiClient *api.Client, hostname, owner, repo string) error { + apiClient *api.Client, hostname, owner, repo string) { opts.IO.DetectTerminalTheme() if err := opts.IO.StartPager(); err != nil { @@ -232,14 +283,12 @@ func renderAllFiles(opts *PreviewOptions, cs *iostreams.ColorScheme, skill disco fmt.Fprintln(out) } } - - return nil } // renderInteractive shows the file tree, then a picker to browse individual files. func renderInteractive(opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, files []discovery.SkillFile, renderedSkillMD string, extraFiles []discovery.SkillFile, - apiClient *api.Client, hostname, owner, repo string) error { + apiClient *api.Client, hostname, owner, repo string) { // Show the file tree to stderr so it persists above the prompt fmt.Fprintf(opts.IO.ErrOut, "\n%s\n", cs.Bold(skill.DisplayName()+"/")) @@ -265,7 +314,7 @@ func renderInteractive(opts *PreviewOptions, cs *iostreams.ColorScheme, skill di idx, err := opts.Prompter.Select("View a file (Esc to exit):", "", choices) if err != nil { - return nil //nolint:nilerr // Prompter returns error on Esc/Ctrl-C; treat as graceful exit + return // Prompter returns error on Esc/Ctrl-C; treat as graceful exit } var content string diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 474ce88b5aa..a5d5554ffe9 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -11,6 +11,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -74,7 +75,7 @@ func TestNewCmdPreview(t *testing.T) { } var gotOpts *PreviewOptions - cmd := NewCmdPreview(f, func(opts *PreviewOptions) error { + cmd := NewCmdPreview(f, &telemetry.NoOpService{}, func(opts *PreviewOptions) error { gotOpts = opts return nil }) @@ -332,6 +333,7 @@ func TestPreviewRun(t *testing.T) { tt.opts.IO = ios tt.opts.Prompter = &prompter.PrompterMock{} + tt.opts.Telemetry = &telemetry.NoOpService{} err := previewRun(tt.opts) @@ -354,6 +356,7 @@ func TestPreviewRun_UnsupportedHost(t *testing.T) { IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, repo: ghrepo.NewWithHost("github", "awesome-copilot", "acme.ghes.com"), + Telemetry: &telemetry.NoOpService{}, }) require.ErrorContains(t, err, "supports only github.com") } @@ -415,6 +418,7 @@ func TestPreviewRun_Interactive(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, repo: ghrepo.New("owner", "repo"), + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -510,6 +514,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { Prompter: pm, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -593,6 +598,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { renderCalls++ return fmt.Sprintf("rendered:%s", filePath) }, + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -618,6 +624,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -724,6 +731,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -761,6 +769,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -793,6 +802,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -802,3 +812,214 @@ func TestPreviewRun_RenderLimits(t *testing.T) { assert.Contains(t, out, "could not fetch file") }) } + +func TestPreviewRun_InteractiveTelemetryCapturesSelectedSkillName(t *testing.T) { + skillContent := "# Selected Skill\n\nContent here." + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/alpha", "type": "tree", "sha": "tree-a"}, + {"path": "skills/alpha/SKILL.md", "type": "blob", "sha": "blob-a"}, + {"path": "skills/beta", "type": "tree", "sha": "tree-b"}, + {"path": "skills/beta/SKILL.md", "type": "blob", "sha": "blob-b"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/tree-b"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob-b", "size": 40} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob-b"), + httpmock.StringResponse(`{"sha": "blob-b", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "public", + }), + ) + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + return 1, nil // select "beta" + }, + } + + recorder := &telemetry.EventRecorderSpy{} + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + Telemetry: recorder, + repo: ghrepo.New("owner", "repo"), + // SkillName intentionally left empty to simulate interactive selection + } + + err := previewRun(opts) + require.NoError(t, err) + + // Verify the telemetry event captured the interactively-selected skill name, not empty string + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "skill_preview", event.Type) + assert.Equal(t, "beta", event.Dimensions["skill_name"], "telemetry should capture the selected skill name, not the empty opts.SkillName") +} + +func TestPreviewRun_TelemetryVisibility(t *testing.T) { + skillContent := heredoc.Doc(` + --- + name: my-skill + description: test + --- + # My Skill + Body. + `) + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + tests := []struct { + name string + visibility string + visibilityErr bool + wantSkillNames string + }{ + { + name: "public repo includes skill names", + visibility: "public", + wantSkillNames: "my-skill", + }, + { + name: "private repo excludes skill names", + visibility: "private", + }, + { + name: "internal repo excludes skill names", + visibility: "internal", + }, + { + name: "API error omits visibility and skill names", + visibilityErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSKILL"), + httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + if tt.visibilityErr { + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.StatusStringResponse(500, "server error"), + ) + } else { + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": tt.visibility, + }), + ) + } + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + recorder := &telemetry.EventRecorderSpy{} + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + Telemetry: recorder, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + require.NoError(t, err) + + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "skill_preview", event.Type) + + // skill_host_type is always recorded (categorized, no raw hostname for enterprise/tenancy). + assert.Equal(t, "github.com", event.Dimensions["skill_host_type"]) + + if tt.visibilityErr { + assert.Equal(t, "unknown", event.Dimensions["repo_visibility"], + "visibility fetch errors should emit repo_visibility=\"unknown\" so the fallback is distinguishable from a successful fetch") + } else { + assert.Equal(t, tt.visibility, event.Dimensions["repo_visibility"]) + } + + // Owner, repo, and skill name are only included when the repo + // is public; for private/internal/unknown they are omitted to + // avoid leaking identifiers of non-public repositories. + if tt.wantSkillNames != "" { + assert.Equal(t, "owner", event.Dimensions["skill_owner"]) + assert.Equal(t, "repo", event.Dimensions["skill_repo"]) + assert.Equal(t, tt.wantSkillNames, event.Dimensions["skill_name"]) + } else { + assert.Empty(t, event.Dimensions["skill_owner"]) + assert.Empty(t, event.Dimensions["skill_repo"]) + assert.Empty(t, event.Dimensions["skill_name"]) + } + }) + } +} diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 5bcc15bda3a..5f510aae2d2 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -15,6 +15,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" @@ -48,6 +49,7 @@ var SkillSearchFields = []string{ type SearchOptions struct { IO *iostreams.IOStreams + Telemetry ghtelemetry.EventRecorder HttpClient func() (*http.Client, error) Config func() (gh.Config, error) Prompter prompter.Prompter @@ -62,9 +64,10 @@ type SearchOptions struct { } // NewCmdSearch creates the "skills search" command. -func NewCmdSearch(f *cmdutil.Factory, runF func(*SearchOptions) error) *cobra.Command { +func NewCmdSearch(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*SearchOptions) error) *cobra.Command { opts := &SearchOptions{ IO: f.IOStreams, + Telemetry: telemetry, HttpClient: f.HttpClient, Config: f.Config, Prompter: f.Prompter, @@ -552,6 +555,13 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { return nil } + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_search_install", + Measures: ghtelemetry.Measures{ + "install_count": int64(len(indices)), + }, + }) + // Prompt for target agent host (once for all selected skills) hostNames := registry.AgentNames() hostIdx, err := opts.Prompter.Select("Select target agent:", "", hostNames) diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index bdfe3ba1913..763ca2124e1 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -8,6 +8,8 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -102,7 +104,7 @@ func TestNewCmdSearch(t *testing.T) { t.Run(tt.name, func(t *testing.T) { f := &cmdutil.Factory{} var gotOpts *SearchOptions - cmd := NewCmdSearch(f, func(opts *SearchOptions) error { + cmd := NewCmdSearch(f, &telemetry.NoOpService{}, func(opts *SearchOptions) error { gotOpts = opts return nil }) @@ -372,6 +374,7 @@ func TestSearchRun(t *testing.T) { ios.SetStdoutTTY(tt.tty) ios.SetStderrTTY(tt.tty) tt.opts.IO = ios + tt.opts.Telemetry = &telemetry.NoOpService{} defer reg.Verify(t) err := searchRun(tt.opts) @@ -576,3 +579,75 @@ func TestDeduplicateByName_Namespaced(t *testing.T) { assert.NotEqual(t, "org/repo6", s.Repo) } } + +// TestSearchRun_TelemetryRecordsInstallFromResults verifies that when a +// user searches, picks one or more results interactively, and proceeds to +// install them, the search command records a telemetry event capturing +// that the search led to an install attempt. This is the key signal for +// measuring the value of search results: of the searches that ran, how +// many converted to an install? +func TestSearchRun_TelemetryRecordsInstallFromResults(t *testing.T) { + codeResponse := `{"total_count": 1, "incomplete_results": false, "items": [ + {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "sha": "abc123", + "repository": {"full_name": "org/repo"}} + ]}` + + reg := &httpmock.Registry{} + defer reg.Verify(t) + // Keyword search fires path + owner + primary (3 requests). + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StringResponse(codeResponse), + ) + } + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + ios.SetStdinTTY(true) + + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + // Select the single result. + return []int{0}, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + // First Select: target agent (0). Second Select: scope (0). + return 0, nil + }, + } + + recorder := &telemetry.EventRecorderSpy{} + + err := searchRun(&SearchOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + Prompter: pm, + Telemetry: recorder, + ExecutablePath: "/nonexistent/gh", // install subprocess will fail; failures are logged, not fatal. + Query: "terraform", + Page: 1, + Limit: defaultLimit, + }) + require.NoError(t, err) + + // The search command no longer records a separate skill_search event; + // only the follow-up skill_search_install event fires when the user + // proceeds to install from the results. + require.Len(t, recorder.Events, 1) + + installEvent := recorder.Events[0] + assert.Equal(t, "skill_search_install", installEvent.Type, + "an install triggered from search results should be recorded as a distinct event") + assert.Equal(t, int64(1), installEvent.Measures["install_count"], + "install_count captures how many results the user chose to install") + // The skill_search_install event must not carry the query or owner: + // these were intentionally removed so that installs from search are + // not linked back to the search terms at the telemetry layer. + assert.Empty(t, installEvent.Dimensions["query"], + "skill_search_install must not record the search query") + assert.Empty(t, installEvent.Dimensions["owner"], + "skill_search_install must not record the search owner filter") +} diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 1dadd3b1f4e..05a87c38651 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -2,6 +2,7 @@ package skills import ( "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/pkg/cmd/skills/install" "github.com/cli/cli/v2/pkg/cmd/skills/preview" "github.com/cli/cli/v2/pkg/cmd/skills/publish" @@ -12,7 +13,7 @@ import ( ) // NewCmdSkills returns the top-level "skill" command. -func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { +func NewCmdSkills(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder) *cobra.Command { cmd := &cobra.Command{ Use: "skill ", Short: "Install and manage agent skills (preview)", @@ -40,12 +41,16 @@ func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { # Validate skills for publishing $ gh skill publish --dry-run `), + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + telemetry.SetSampleRate(ghtelemetry.SAMPLE_ALL) + return nil + }, } - cmd.AddCommand(install.NewCmdInstall(f, nil)) - cmd.AddCommand(preview.NewCmdPreview(f, nil)) + cmd.AddCommand(install.NewCmdInstall(f, telemetry, nil)) + cmd.AddCommand(preview.NewCmdPreview(f, telemetry, nil)) cmd.AddCommand(publish.NewCmdPublish(f, nil)) - cmd.AddCommand(search.NewCmdSearch(f, nil)) + cmd.AddCommand(search.NewCmdSearch(f, telemetry, nil)) cmd.AddCommand(update.NewCmdUpdate(f, nil)) return cmd diff --git a/pkg/cmd/skills/skills_test.go b/pkg/cmd/skills/skills_test.go new file mode 100644 index 00000000000..eb8bb465c0e --- /dev/null +++ b/pkg/cmd/skills/skills_test.go @@ -0,0 +1,19 @@ +package skills_test + +import ( + "testing" + + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmd/skills" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/stretchr/testify/require" +) + +func TestSkillCommandsAreSampledAt100(t *testing.T) { + spy := &telemetry.CommandRecorderSpy{} + factory := &cmdutil.Factory{} + cmd := skills.NewCmdSkills(factory, spy) + cmd.PersistentPreRunE(nil, []string{}) + require.Equal(t, ghtelemetry.SAMPLE_ALL, spy.LastSampleRate) +} From 082f15a8fd1419d95d9296161d3203842f3c9794 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:22:09 +0100 Subject: [PATCH 07/29] Add support for installation in multiple agent hosts in `gh skills install` (#13209) * add support for installation in multiple agent host * print correct dir in warning * remove dir as it depends on user vs project installation scope * Move comment closer to assertion in registry test Move the explanatory comment from above the map initialization to directly above the assertions it describes, per review feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * List supported agent names and IDs in help text Replace the self-referencing "run --help" sentence with an inline list of all supported --agent values showing Name (id) pairs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use heredoc.Docf for Kiro CLI post-install hint Replace individual fmt.Fprintln calls with a single heredoc.Docf block for the Kiro CLI post-install guidance, per review feedback. Also shorten the --agent flag usage line by overriding the auto-generated enum list with a reference to the supported values in the help text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/registry/registry.go | 256 +++++++++++++++++++++- internal/skills/registry/registry_test.go | 17 +- pkg/cmd/skills/install/install.go | 85 +++++-- pkg/cmd/skills/install/install_test.go | 92 +++++++- pkg/cmd/skills/publish/publish.go | 5 + 5 files changed, 434 insertions(+), 21 deletions(-) diff --git a/internal/skills/registry/registry.go b/internal/skills/registry/registry.go index b112d361a50..a5e018176cf 100644 --- a/internal/skills/registry/registry.go +++ b/internal/skills/registry/registry.go @@ -34,7 +34,16 @@ const ( ) // Agents contains all known agent hosts. +// +// The slice is ordered so that the most widely used agents appear first, +// followed by the rest in alphabetical order. This order is used for +// interactive selection, help output, and flag enum suggestions. +// +// Agents sharing a ProjectDir (such as the shared .agents/skills directory) +// install skills to the same project-scope location, so selecting multiple +// such agents writes each skill only once. var Agents = []AgentHost{ + // Popular agents, listed first for discoverability. { ID: "github-copilot", Name: "GitHub Copilot", @@ -60,7 +69,7 @@ var Agents = []AgentHost{ UserDir: ".codex/skills", }, { - ID: "gemini", + ID: "gemini-cli", Name: "Gemini CLI", ProjectDir: sharedProjectSkillsDir, UserDir: ".gemini/skills", @@ -71,6 +80,242 @@ var Agents = []AgentHost{ ProjectDir: sharedProjectSkillsDir, UserDir: ".gemini/antigravity/skills", }, + + // All other supported agents, alphabetical by ID. + { + ID: "adal", + Name: "AdaL", + ProjectDir: ".adal/skills", + UserDir: ".adal/skills", + }, + { + ID: "amp", + Name: "Amp", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/agents/skills", + }, + { + ID: "augment", + Name: "Augment", + ProjectDir: ".augment/skills", + UserDir: ".augment/skills", + }, + { + ID: "bob", + Name: "IBM Bob", + ProjectDir: ".bob/skills", + UserDir: ".bob/skills", + }, + { + ID: "cline", + Name: "Cline", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".agents/skills", + }, + { + ID: "codebuddy", + Name: "CodeBuddy", + ProjectDir: ".codebuddy/skills", + UserDir: ".codebuddy/skills", + }, + { + ID: "command-code", + Name: "Command Code", + ProjectDir: ".commandcode/skills", + UserDir: ".commandcode/skills", + }, + { + ID: "continue", + Name: "Continue", + ProjectDir: ".continue/skills", + UserDir: ".continue/skills", + }, + { + ID: "cortex", + Name: "Cortex Code", + ProjectDir: ".cortex/skills", + UserDir: ".snowflake/cortex/skills", + }, + { + ID: "crush", + Name: "Crush", + ProjectDir: ".crush/skills", + UserDir: ".config/crush/skills", + }, + { + ID: "deepagents", + Name: "Deep Agents", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".deepagents/agent/skills", + }, + { + ID: "droid", + Name: "Droid", + ProjectDir: ".factory/skills", + UserDir: ".factory/skills", + }, + { + ID: "firebender", + Name: "Firebender", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".firebender/skills", + }, + { + ID: "goose", + Name: "Goose", + ProjectDir: ".goose/skills", + UserDir: ".config/goose/skills", + }, + { + ID: "iflow-cli", + Name: "iFlow CLI", + ProjectDir: ".iflow/skills", + UserDir: ".iflow/skills", + }, + { + ID: "junie", + Name: "Junie", + ProjectDir: ".junie/skills", + UserDir: ".junie/skills", + }, + { + ID: "kilo", + Name: "Kilo Code", + ProjectDir: ".kilocode/skills", + UserDir: ".kilocode/skills", + }, + { + ID: "kimi-cli", + Name: "Kimi Code CLI", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/agents/skills", + }, + { + ID: "kiro-cli", + Name: "Kiro CLI", + ProjectDir: ".kiro/skills", + UserDir: ".kiro/skills", + }, + { + ID: "kode", + Name: "Kode", + ProjectDir: ".kode/skills", + UserDir: ".kode/skills", + }, + { + ID: "mcpjam", + Name: "MCPJam", + ProjectDir: ".mcpjam/skills", + UserDir: ".mcpjam/skills", + }, + { + ID: "mistral-vibe", + Name: "Mistral Vibe", + ProjectDir: ".vibe/skills", + UserDir: ".vibe/skills", + }, + { + ID: "mux", + Name: "Mux", + ProjectDir: ".mux/skills", + UserDir: ".mux/skills", + }, + { + ID: "neovate", + Name: "Neovate", + ProjectDir: ".neovate/skills", + UserDir: ".neovate/skills", + }, + { + ID: "openclaw", + Name: "OpenClaw", + ProjectDir: "skills", + UserDir: ".openclaw/skills", + }, + { + ID: "opencode", + Name: "OpenCode", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/opencode/skills", + }, + { + ID: "openhands", + Name: "OpenHands", + ProjectDir: ".openhands/skills", + UserDir: ".openhands/skills", + }, + { + ID: "pi", + Name: "Pi", + ProjectDir: ".pi/skills", + UserDir: ".pi/agent/skills", + }, + { + ID: "pochi", + Name: "Pochi", + ProjectDir: ".pochi/skills", + UserDir: ".pochi/skills", + }, + { + ID: "qoder", + Name: "Qoder", + ProjectDir: ".qoder/skills", + UserDir: ".qoder/skills", + }, + { + ID: "qwen-code", + Name: "Qwen Code", + ProjectDir: ".qwen/skills", + UserDir: ".qwen/skills", + }, + { + ID: "replit", + Name: "Replit", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/agents/skills", + }, + { + ID: "roo", + Name: "Roo Code", + ProjectDir: ".roo/skills", + UserDir: ".roo/skills", + }, + { + ID: "trae", + Name: "Trae", + ProjectDir: ".trae/skills", + UserDir: ".trae/skills", + }, + { + ID: "trae-cn", + Name: "Trae CN", + ProjectDir: ".trae/skills", + UserDir: ".trae-cn/skills", + }, + { + ID: "universal", + Name: "Universal", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/agents/skills", + }, + { + ID: "warp", + Name: "Warp", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".agents/skills", + }, + { + ID: "windsurf", + Name: "Windsurf", + ProjectDir: ".windsurf/skills", + UserDir: ".codeium/windsurf/skills", + }, + { + ID: "zencoder", + Name: "Zencoder", + ProjectDir: ".zencoder/skills", + UserDir: ".zencoder/skills", + }, } // FindByID returns the agent host with the given ID, or an error if not found. @@ -97,6 +342,15 @@ func AgentIDs() []string { return ids } +// AgentHelpList returns a newline-separated bulleted list of agents for help text. +func AgentHelpList() string { + lines := make([]string, len(Agents)) + for i, h := range Agents { + lines[i] = fmt.Sprintf(" - %s (%s)", h.Name, h.ID) + } + return strings.Join(lines, "\n") +} + // AgentNames returns the display names of all agents for prompting. func AgentNames() []string { names := make([]string, len(Agents)) diff --git a/internal/skills/registry/registry_test.go b/internal/skills/registry/registry_test.go index 003a28afa1f..bd0c4470963 100644 --- a/internal/skills/registry/registry_test.go +++ b/internal/skills/registry/registry_test.go @@ -19,7 +19,7 @@ func TestFindByID(t *testing.T) { {name: "claude-code", id: "claude-code", wantName: "Claude Code"}, {name: "cursor", id: "cursor", wantName: "Cursor"}, {name: "codex", id: "codex", wantName: "Codex"}, - {name: "gemini", id: "gemini", wantName: "Gemini CLI"}, + {name: "gemini-cli", id: "gemini-cli", wantName: "Gemini CLI"}, {name: "antigravity", id: "antigravity", wantName: "Antigravity"}, {name: "unknown agent", id: "nonexistent", wantErr: "unknown agent"}, } @@ -89,7 +89,7 @@ func TestInstallDir(t *testing.T) { }, { name: "gemini project scope", - hostID: "gemini", + hostID: "gemini-cli", scope: ScopeProject, gitRoot: "/tmp/monalisa-repo", homeDir: "/home/monalisa", @@ -167,7 +167,18 @@ func TestRepoNameFromRemote(t *testing.T) { func TestUniqueProjectDirs(t *testing.T) { dirs := UniqueProjectDirs() - assert.Equal(t, []string{".agents/skills", ".claude/skills"}, dirs) + seen := map[string]int{} + for _, d := range dirs { + seen[d]++ + } + // The shared .agents/skills dir and .claude/skills must both be present + // and listed exactly once each. + assert.Equal(t, 1, seen[".agents/skills"], "expected .agents/skills exactly once") + assert.Equal(t, 1, seen[".claude/skills"], "expected .claude/skills exactly once") + // No project dir should appear more than once. + for d, n := range seen { + assert.LessOrEqualf(t, n, 1, "project dir %q appears %d times", d, n) + } } func TestScopeLabels(t *testing.T) { diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index d4a05440e7b..5f715ff7ef9 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -80,24 +80,24 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru Install agent skills from a GitHub repository or local directory into your local environment. Skills are placed in a host-specific directory at either project scope (inside the current git repository) or user - scope (in your home directory, available everywhere). Supported hosts - and their storage directories are (project, user): + scope (in your home directory, available everywhere). - - GitHub Copilot (%[1]s.agents/skills%[1]s, %[1]s~/.copilot/skills%[1]s) - - Claude Code (%[1]s.claude/skills%[1]s, %[1]s~/.claude/skills%[1]s) - - Cursor (%[1]s.agents/skills%[1]s, %[1]s~/.cursor/skills%[1]s) - - Codex (%[1]s.agents/skills%[1]s, %[1]s~/.codex/skills%[1]s) - - Gemini CLI (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/skills%[1]s) - - Antigravity (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/antigravity/skills%[1]s) + A wide range of AI coding agents are supported, including GitHub + Copilot, Claude Code, Cursor, Codex, Gemini CLI, Antigravity, Amp, + Goose, Junie, OpenCode, Windsurf, and many more. + + Supported %[1]s--agent%[1]s values: + + %[2]s Use %[1]s--agent%[1]s and %[1]s--scope%[1]s to control placement, or %[1]s--dir%[1]s for a custom directory. The default scope is %[1]sproject%[1]s, and the default agent is %[1]sgithub-copilot%[1]s (when running non-interactively). - At project scope, GitHub Copilot, Cursor, Codex, Gemini CLI, and - Antigravity all use the shared %[1]s.agents/skills%[1]s directory. If you - select multiple hosts that resolve to the same destination, each skill is - installed there only once. + At project scope, several agents (including GitHub Copilot, Cursor, + Codex, Gemini CLI, Antigravity, Amp, Cline, OpenCode, and Warp) share + the %[1]s.agents/skills%[1]s directory. If you select multiple hosts that + resolve to the same destination, each skill is installed there only once. The first argument is a GitHub repository in %[1]sOWNER/REPO%[1]s format. Use %[1]s--from-local%[1]s to install from a local directory instead. @@ -133,7 +133,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru When run interactively, the command prompts for any missing arguments. When run non-interactively, %[1]srepository%[1]s and a skill name are required. - `, "`"), + `, "`", registry.AgentHelpList()), Example: heredoc.Doc(` # Interactive: choose repo, skill, and agent $ gh skill install @@ -198,7 +198,8 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru }, } - cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Target agent") + agentFlag := cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Target agent") + agentFlag.Usage = "Target agent (see supported values above)" cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope") cmd.Flags().StringVar(&opts.Pin, "pin", "", "Pin to a specific git tag or commit SHA") cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)") @@ -336,6 +337,7 @@ func installRun(opts *InstallOptions) error { printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed) printReviewHint(opts.IO.ErrOut, cs, repoSource, resolved.SHA, result.Installed) + printHostHints(opts.IO.ErrOut, cs, plan.hosts, result.Installed, result.Dir, gitRoot) } if err != nil { @@ -474,6 +476,7 @@ func runLocalInstall(opts *InstallOptions) error { printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed) printReviewHint(opts.IO.ErrOut, cs, "", "", result.Installed) + printHostHints(opts.IO.ErrOut, cs, plan.hosts, result.Installed, result.Dir, gitRoot) } return nil @@ -789,8 +792,18 @@ func resolveHosts(opts *InstallOptions, canPrompt bool) ([]*registry.AgentHost, } fmt.Fprintln(opts.IO.ErrOut) - names := registry.AgentNames() - indices, err := opts.Prompter.MultiSelect("Select target agent(s):", []string{names[0]}, names) + labels := make([]string, len(registry.Agents)) + defaultLabel := "" + for i, h := range registry.Agents { + labels[i] = h.Name + if h.ID == registry.DefaultAgentID { + defaultLabel = labels[i] + } + } + if defaultLabel == "" { + defaultLabel = labels[0] + } + indices, err := opts.Prompter.MultiSelect("Select target agent(s):", []string{defaultLabel}, labels) if err != nil { return nil, err } @@ -1058,3 +1071,43 @@ func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo, sha string, s } fmt.Fprintln(w) } + +// printHostHints prints any agent-specific post-install guidance for the +// hosts that were installed to. Most agents need no extra steps; this is +// currently used for Kiro CLI, which requires skills to be registered as +// resources on a custom agent. The path in the example is derived from +// the actual install directory so it matches the chosen scope or --dir. +func printHostHints(w io.Writer, cs *iostreams.ColorScheme, hosts []*registry.AgentHost, installed []string, installDir, gitRoot string) { + if len(installed) == 0 { + return + } + for _, h := range hosts { + if h.ID == "kiro-cli" { + fmt.Fprintln(w) + fmt.Fprint(w, heredoc.Docf(` + %s Kiro CLI: register these skills on a custom agent by adding them to + .kiro/agents/.json under "resources", for example: + + { + "resources": ["skill://%s/**/SKILL.md"] + } + `, cs.WarningIcon(), kiroResourcePath(installDir, gitRoot))) + fmt.Fprintln(w) + return + } + } +} + +// kiroResourcePath returns a slash-separated path suitable for use in the +// "resources" field of a Kiro agent config. When the install directory is +// inside the current git repository the path is made relative to the repo +// root so the example works for project-scoped agent configs; otherwise +// the absolute install path is used (e.g. for --scope user or --dir). +func kiroResourcePath(installDir, gitRoot string) string { + if gitRoot != "" && installDir != "" { + if rel, err := filepath.Rel(gitRoot, installDir); err == nil && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) { + return filepath.ToSlash(rel) + } + } + return filepath.ToSlash(installDir) +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 0560625f7fc..120738fd052 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -16,6 +16,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -1403,7 +1404,15 @@ func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { pm := &prompter.PrompterMock{ MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { - return []int{0, 2}, nil // GitHub Copilot + Cursor share .agents/skills + // Select two agents that share the .agents/skills project dir + // (GitHub Copilot and Cursor) to exercise deduplication. + var indices []int + for i, label := range options { + if label == "GitHub Copilot" || label == "Cursor" { + indices = append(indices, i) + } + } + return indices, nil }, SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { return 0, nil // project scope @@ -1947,6 +1956,87 @@ func Test_printReviewHint(t *testing.T) { } } +func Test_printHostHints(t *testing.T) { + kiro := ®istry.AgentHost{ID: "kiro-cli", Name: "Kiro CLI", ProjectDir: ".kiro/skills", UserDir: ".kiro/skills"} + copilot := ®istry.AgentHost{ID: "copilot-cli", Name: "GitHub Copilot CLI", ProjectDir: ".github/skills"} + + tests := []struct { + name string + hosts []*registry.AgentHost + installed []string + installDir string + gitRoot string + wantSub []string + wantNot []string + }{ + { + name: "no installs produces no output", + hosts: []*registry.AgentHost{kiro}, + installed: nil, + installDir: "/repo/.kiro/skills", + gitRoot: "/repo", + wantNot: []string{"Kiro CLI"}, + }, + { + name: "non-kiro host produces no output", + hosts: []*registry.AgentHost{copilot}, + installed: []string{"s1"}, + installDir: "/repo/.github/skills", + gitRoot: "/repo", + wantNot: []string{"Kiro CLI"}, + }, + { + name: "kiro project scope uses relative path", + hosts: []*registry.AgentHost{kiro}, + installed: []string{"s1"}, + installDir: filepath.Join("/repo", ".kiro", "skills"), + gitRoot: "/repo", + wantSub: []string{"Kiro CLI", `"skill://.kiro/skills/**/SKILL.md"`}, + }, + { + name: "kiro user scope uses absolute install dir", + hosts: []*registry.AgentHost{kiro}, + installed: []string{"s1"}, + installDir: "/home/user/.kiro/skills", + gitRoot: "/repo", + wantSub: []string{`"skill:///home/user/.kiro/skills/**/SKILL.md"`}, + wantNot: []string{`skill://.kiro/skills`}, + }, + { + name: "kiro custom dir outside git root uses absolute path", + hosts: []*registry.AgentHost{kiro}, + installed: []string{"s1"}, + installDir: "/tmp/my-skills", + gitRoot: "/repo", + wantSub: []string{`"skill:///tmp/my-skills/**/SKILL.md"`}, + }, + { + name: "kiro without git root falls back to install dir", + hosts: []*registry.AgentHost{kiro}, + installed: []string{"s1"}, + installDir: "/home/user/.kiro/skills", + gitRoot: "", + wantSub: []string{`"skill:///home/user/.kiro/skills/**/SKILL.md"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cs := ios.ColorScheme() + var buf strings.Builder + printHostHints(&buf, cs, tt.hosts, tt.installed, tt.installDir, tt.gitRoot) + got := buf.String() + for _, s := range tt.wantSub { + assert.Contains(t, got, s) + } + for _, s := range tt.wantNot { + assert.NotContains(t, got, s) + } + }) + } +} + func Test_printPreInstallDisclaimer(t *testing.T) { ios, _, _, _ := iostreams.Test() cs := ios.ColorScheme() diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index d8278187694..6364846840f 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -859,6 +859,11 @@ func checkInstalledSkillDirs(gitClient *git.Client, repoDir string) []publishDia var diagnostics []publishDiagnostic for _, relPath := range registry.UniqueProjectDirs() { + // Skip non-hidden project dirs (such as "skills") to avoid + // flagging the canonical authoring layout used when publishing. + if !strings.HasPrefix(relPath, ".") { + continue + } absPath := filepath.Join(repoDir, relPath) if _, err := os.Stat(absPath); os.IsNotExist(err) { continue From eaa018545aff23f22744a0faade18b58c5c4aaf2 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 20 Apr 2026 11:06:02 +0200 Subject: [PATCH 08/29] refactor: decouple hidden-dir filtering from discovery layer Move --allow-hidden-dirs filtering logic from the discovery package to the install command, addressing review feedback. Discovery functions now always return all skills (including hidden-dir), and callers decide how to handle them. Changes: - DiscoverSkillsWithOptions/DiscoverLocalSkillsWithOptions always return hidden-dir skills; callers filter using IsHiddenDirConvention() - DiscoverSkills/DiscoverLocalSkills (convenience wrappers) auto-filter hidden-dir skills for backward compatibility with preview/update/publish - Remove --allow-hidden-dirs reference from discovery error messages - Add filterHiddenDirSkills in install.go with caller-side flag logic - Inline warning using heredoc.Docf, remove printHiddenDirWarning - Add inline comments in matchHiddenDirConventions (babakks nitpicks) - Add non-hidden-namespaced dir and no-skills-at-all test cases - Add --allow-hidden-dirs tests in TestNewCmdInstall, TestInstallRun, and TestRunLocalInstall Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/discovery/discovery.go | 136 +++++++++- internal/skills/discovery/discovery_test.go | 283 ++++++++++++++++++++ pkg/cmd/skills/install/install.go | 75 +++++- pkg/cmd/skills/install/install_test.go | 179 +++++++++++++ 4 files changed, 659 insertions(+), 14 deletions(-) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 2d6c1ee7256..b2c8baaed93 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -66,6 +66,8 @@ func (s Skill) DisplayName() string { return "[plugins] " + name case "root": return "[root] " + name + case "hidden-dir", "hidden-dir-namespaced": + return "[hidden-dir] " + name default: return name } @@ -82,6 +84,23 @@ func (s Skill) InstallName() string { return s.Name } +// IsHiddenDirConvention returns true if the skill was discovered in a hidden +// (dot-prefixed) directory such as .claude/skills/ or .agents/skills/. +func (s Skill) IsHiddenDirConvention() bool { + return s.Convention == "hidden-dir" || s.Convention == "hidden-dir-namespaced" +} + +// HasHiddenDirSkills returns true if any of the given skills were discovered +// in hidden directories. +func HasHiddenDirSkills(skills []Skill) bool { + for _, s := range skills { + if s.IsHiddenDirConvention() { + return true + } + } + return false +} + // ResolvedRef contains the resolved git reference and its SHA. type ResolvedRef struct { Ref string // fully qualified ref (refs/heads/*, refs/tags/*) or commit SHA @@ -393,8 +412,87 @@ func matchSkillConventions(entry treeEntry) *skillMatch { return nil } -// DiscoverSkills finds all skills in a repository at the given commit SHA. +// matchHiddenDirConventions checks if a blob path matches a skill convention +// under a hidden (dot-prefixed) root directory. These patterns mirror the +// standard skills/ conventions but rooted under .{host}/skills/: +// +// - .{host}/skills/*/SKILL.md -> "hidden-dir" +// - .{host}/skills/{scope}/*/SKILL.md -> "hidden-dir-namespaced" +func matchHiddenDirConventions(entry treeEntry) *skillMatch { + if path.Base(entry.Path) != "SKILL.md" { + return nil + } + + // .{host}/skills/* + // .{host}/skills/{scope}/* + dir := path.Dir(entry.Path) + skillName := path.Base(dir) + + if !validateName(skillName) { + return nil + } + + // .{host}/skills + // .{host}/skills/{scope} + parentDir := path.Dir(dir) + + // .{host}/skills/*/SKILL.md + if path.Base(parentDir) == "skills" { + hiddenRoot := path.Dir(parentDir) + if path.Dir(hiddenRoot) == "." && strings.HasPrefix(hiddenRoot, ".") { + return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "hidden-dir"} + } + } + + // .{host}/skills/{scope}/*/SKILL.md + grandparentDir := path.Dir(parentDir) + if path.Base(grandparentDir) == "skills" { + hiddenRoot := path.Dir(grandparentDir) + if path.Dir(hiddenRoot) == "." && strings.HasPrefix(hiddenRoot, ".") { + namespace := path.Base(parentDir) + if !validateName(namespace) { + return nil + } + return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "hidden-dir-namespaced"} + } + } + + return nil +} + +// DiscoverOptions controls optional discovery behaviors. +type DiscoverOptions struct { +} + +// DiscoverSkills finds all non-hidden-dir skills in a repository at the given +// commit SHA. Hidden-dir skills are excluded; use DiscoverSkillsWithOptions to +// retrieve all skills including those in hidden directories. func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([]Skill, error) { + all, err := DiscoverSkillsWithOptions(client, host, owner, repo, commitSHA, DiscoverOptions{}) + if err != nil { + return nil, err + } + var skills []Skill + for _, s := range all { + if !s.IsHiddenDirConvention() { + skills = append(skills, s) + } + } + if len(skills) == 0 { + return nil, fmt.Errorf( + "no skills found in %s/%s\n"+ + " Expected skills in skills/*/SKILL.md, skills/{scope}/*/SKILL.md,\n"+ + " */SKILL.md, or plugins/*/skills/*/SKILL.md\n"+ + " This repository may be a curated list rather than a skills publisher", + owner, repo, + ) + } + return skills, nil +} + +// DiscoverSkillsWithOptions finds all skills in a repository at the given +// commit SHA, with configurable discovery behavior. +func DiscoverSkillsWithOptions(client *api.Client, host, owner, repo, commitSHA string, opts DiscoverOptions) ([]Skill, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(commitSHA)) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { @@ -419,6 +517,9 @@ func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([] continue } m := matchSkillConventions(entry) + if m == nil { + m = matchHiddenDirConventions(entry) + } if m == nil { continue } @@ -703,9 +804,35 @@ func FetchBlob(client *api.Client, host, owner, repo, sha string) (string, error return string(decoded), nil } -// DiscoverLocalSkills finds skills in a local directory using the same -// conventions as remote discovery. +// DiscoverLocalSkills finds non-hidden-dir skills in a local directory using +// the same conventions as remote discovery. Hidden-dir skills are excluded; use +// DiscoverLocalSkillsWithOptions to retrieve all skills including those in +// hidden directories. func DiscoverLocalSkills(dir string) ([]Skill, error) { + all, err := DiscoverLocalSkillsWithOptions(dir, DiscoverOptions{}) + if err != nil { + return nil, err + } + var skills []Skill + for _, s := range all { + if !s.IsHiddenDirConvention() { + skills = append(skills, s) + } + } + if len(skills) == 0 { + return nil, fmt.Errorf( + "no skills found in %s\n"+ + " Expected SKILL.md in the directory, or skills in skills/*/SKILL.md,\n"+ + " skills/{scope}/*/SKILL.md, */SKILL.md, or plugins/*/skills/*/SKILL.md", + dir, + ) + } + return skills, nil +} + +// DiscoverLocalSkillsWithOptions finds skills in a local directory using the +// same conventions as remote discovery, with configurable discovery behavior. +func DiscoverLocalSkillsWithOptions(dir string, opts DiscoverOptions) ([]Skill, error) { absDir, err := filepath.Abs(dir) if err != nil { return nil, fmt.Errorf("could not resolve path: %w", err) @@ -751,6 +878,9 @@ func DiscoverLocalSkills(dir string) ([]Skill, error) { entry := treeEntry{Path: relPath, Type: "blob"} m := matchSkillConventions(entry) + if m == nil { + m = matchHiddenDirConventions(entry) + } if m == nil { return nil } diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 8929e17871b..41600f21c72 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -116,6 +116,155 @@ func TestMatchSkillConventions(t *testing.T) { } } +func TestMatchHiddenDirConventions(t *testing.T) { + tests := []struct { + name string + path string + wantNil bool + wantName string + wantNamespace string + wantConvention string + }{ + { + name: "claude skills directory", + path: ".claude/skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "hidden-dir", + }, + { + name: "agents skills directory", + path: ".agents/skills/git-commit/SKILL.md", + wantName: "git-commit", + wantConvention: "hidden-dir", + }, + { + name: "github skills directory", + path: ".github/skills/issue-triage/SKILL.md", + wantName: "issue-triage", + wantConvention: "hidden-dir", + }, + { + name: "copilot skills directory", + path: ".copilot/skills/pr-summary/SKILL.md", + wantName: "pr-summary", + wantConvention: "hidden-dir", + }, + { + name: "namespaced hidden dir skill", + path: ".claude/skills/monalisa/code-review/SKILL.md", + wantName: "code-review", + wantNamespace: "monalisa", + wantConvention: "hidden-dir-namespaced", + }, + { + name: "not a SKILL.md file", + path: ".claude/skills/code-review/README.md", + wantNil: true, + }, + { + name: "too shallow - just hidden dir and SKILL.md", + path: ".claude/SKILL.md", + wantNil: true, + }, + { + name: "no skills subdirectory", + path: ".claude/code-review/SKILL.md", + wantNil: true, + }, + { + name: "non-hidden dir does not match", + path: "visible/skills/code-review/SKILL.md", + wantNil: true, + }, + { + name: "non-hidden-namespaced dir does not match", + path: "visible/skills/monalisa/code-review/SKILL.md", + wantNil: true, + }, + { + name: "too deeply nested hidden dir", + path: ".claude/nested/skills/code-review/SKILL.md", + wantNil: true, + }, + { + name: "invalid skill name", + path: ".claude/skills/../SKILL.md", + wantNil: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := matchHiddenDirConventions(treeEntry{Path: tt.path, Type: "blob"}) + if tt.wantNil { + assert.Nil(t, m) + return + } + require.NotNil(t, m) + assert.Equal(t, tt.wantName, m.name) + assert.Equal(t, tt.wantNamespace, m.namespace) + assert.Equal(t, tt.wantConvention, m.convention) + }) + } +} + +func TestHasHiddenDirSkills(t *testing.T) { + tests := []struct { + name string + skills []Skill + want bool + }{ + { + name: "empty list", + skills: nil, + want: false, + }, + { + name: "only standard skills", + skills: []Skill{{Convention: "skills"}, {Convention: "root"}}, + want: false, + }, + { + name: "has hidden-dir skill", + skills: []Skill{{Convention: "skills"}, {Convention: "hidden-dir"}}, + want: true, + }, + { + name: "has hidden-dir-namespaced skill", + skills: []Skill{{Convention: "hidden-dir-namespaced"}}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, HasHiddenDirSkills(tt.skills)) + }) + } +} + +func TestDisplayNameHiddenDir(t *testing.T) { + tests := []struct { + name string + skill Skill + wantName string + }{ + { + name: "hidden-dir skill", + skill: Skill{Name: "code-review", Convention: "hidden-dir"}, + wantName: "[hidden-dir] code-review", + }, + { + name: "hidden-dir-namespaced skill", + skill: Skill{Name: "code-review", Namespace: "monalisa", Convention: "hidden-dir-namespaced"}, + wantName: "[hidden-dir] monalisa/code-review", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, tt.skill.DisplayName()) + }) + } +} + func TestValidateName(t *testing.T) { tests := []struct { name string @@ -740,6 +889,82 @@ func TestDiscoverSkills(t *testing.T) { } } +func TestDiscoverSkillsWithOptions(t *testing.T) { + hiddenDirTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": ".claude/skills/code-review", "type": "tree", "sha": "tree-sha-1"}, + {"path": ".claude/skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": ".agents/skills/git-commit", "type": "tree", "sha": "tree-sha-2"}, + {"path": ".agents/skills/git-commit/SKILL.md", "type": "blob", "sha": "blob-2"}, + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + } + + mixedTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/standard-skill", "type": "tree", "sha": "tree-sha-1"}, + {"path": "skills/standard-skill/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": ".claude/skills/hidden-skill", "type": "tree", "sha": "tree-sha-2"}, + {"path": ".claude/skills/hidden-skill/SKILL.md", "type": "blob", "sha": "blob-2"}, + }, + } + + emptyTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + } + + tests := []struct { + name string + tree map[string]interface{} + wantSkills []string + wantErr string + }{ + { + name: "returns hidden-dir skills", + tree: hiddenDirTree, + wantSkills: []string{"code-review", "git-commit"}, + }, + { + name: "mixed tree returns all skills", + tree: mixedTree, + wantSkills: []string{"hidden-skill", "standard-skill"}, + }, + { + name: "no skills at all", + tree: emptyTree, + wantErr: "no skills found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(tt.tree)) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skills, err := DiscoverSkillsWithOptions(client, "github.com", "monalisa", "octocat-skills", "abc123", DiscoverOptions{}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.Equal(t, tt.wantSkills, names) + }) + } +} + func TestDiscoverSkillByPath(t *testing.T) { tests := []struct { name string @@ -984,6 +1209,64 @@ func TestDiscoverLocalSkills(t *testing.T) { } } +func TestDiscoverLocalSkillsWithOptions(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + wantSkills []string + wantErr string + }{ + { + name: "returns hidden dir skills", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, ".claude", "skills", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# code-review"), 0o644)) + }, + wantSkills: []string{"code-review"}, + }, + { + name: "mixed standard and hidden returns all", + setup: func(t *testing.T, dir string) { + t.Helper() + for _, p := range []string{"skills/standard", ".agents/skills/hidden"} { + skillDir := filepath.Join(dir, filepath.FromSlash(p)) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + name := filepath.Base(p) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + wantSkills: []string{"standard", "hidden"}, + }, + { + name: "no skills at all", + setup: func(t *testing.T, _ string) { t.Helper() }, + wantErr: "no skills found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo") + require.NoError(t, os.MkdirAll(dir, 0o755)) + tt.setup(t, dir) + + skills, err := DiscoverLocalSkillsWithOptions(dir, DiscoverOptions{}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.ElementsMatch(t, tt.wantSkills, names) + }) + } +} + func TestMatchesSkillPath(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 5f715ff7ef9..88cd2673163 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -47,15 +47,16 @@ type InstallOptions struct { GitClient *git.Client Remotes func() (ghContext.Remotes, error) - SkillSource string // owner/repo or local path (when --from-local is set) - SkillName string // possibly with @version suffix - Agent string - Scope string - ScopeChanged bool // true when --scope was explicitly set - Pin string - Dir string // overrides --agent and --scope - Force bool - FromLocal bool // treat SkillSource as a local directory path + SkillSource string // owner/repo or local path (when --from-local is set) + SkillName string // possibly with @version suffix + Agent string + Scope string + ScopeChanged bool // true when --scope was explicitly set + Pin string + Dir string // overrides --agent and --scope + Force bool + FromLocal bool // treat SkillSource as a local directory path + AllowHiddenDirs bool // include skills in dot-prefixed directories repo ghrepo.Interface // set when SkillSource is a GitHub repository localPath string // set when FromLocal is true @@ -161,6 +162,9 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru # Pin to a specific git ref $ gh skill install github/awesome-copilot git-commit --pin v2.0.0 + + # Install skills from hidden directories (e.g. .claude/skills/) + $ gh skill install owner/repo --allow-hidden-dirs `), Aliases: []string{"add"}, Args: cobra.MaximumNArgs(2), @@ -205,6 +209,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)") cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting") cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository") + cmd.Flags().BoolVar(&opts.AllowHiddenDirs, "allow-hidden-dirs", false, "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)") cmdutil.DisableAuthCheckFlag(cmd.Flags().Lookup("from-local")) return cmd @@ -417,12 +422,17 @@ func runLocalInstall(opts *InstallOptions) error { } opts.IO.StartProgressIndicatorWithLabel("Discovering skills") - skills, err := discovery.DiscoverLocalSkills(absSource) + allSkills, err := discovery.DiscoverLocalSkillsWithOptions(absSource, discovery.DiscoverOptions{}) opts.IO.StopProgressIndicator() if err != nil { return err } + skills, err := filterHiddenDirSkills(opts, allSkills) + if err != nil { + return err + } + if canPrompt { fmt.Fprintf(opts.IO.ErrOut, "Found %d skill(s)\n", len(skills)) } @@ -553,7 +563,7 @@ func resolveVersion(opts *InstallOptions, client *api.Client, hostname string) ( func discoverSkills(opts *InstallOptions, client *api.Client, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { opts.IO.StartProgressIndicatorWithLabel("Discovering skills") - skills, err := discovery.DiscoverSkills(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA) + allSkills, err := discovery.DiscoverSkillsWithOptions(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, discovery.DiscoverOptions{}) opts.IO.StopProgressIndicator() if err != nil { var treeTooLarge *discovery.TreeTooLargeError @@ -564,6 +574,10 @@ func discoverSkills(opts *InstallOptions, client *api.Client, hostname string, r } return nil, err } + skills, filterErr := filterHiddenDirSkills(opts, allSkills) + if filterErr != nil { + return nil, filterErr + } logConventions(opts.IO, skills) for _, s := range skills { if !discovery.IsSpecCompliant(s.Name) { @@ -1111,3 +1125,42 @@ func kiroResourcePath(installDir, gitRoot string) string { } return filepath.ToSlash(installDir) } + +// filterHiddenDirSkills separates hidden-dir skills from the full list and +// applies the --allow-hidden-dirs flag logic. When the flag is set, all skills +// are returned and a warning is printed. When the flag is not set, hidden-dir +// skills are excluded and an error is returned if no standard skills remain. +func filterHiddenDirSkills(opts *InstallOptions, allSkills []discovery.Skill) ([]discovery.Skill, error) { + cs := opts.IO.ColorScheme() + + if opts.AllowHiddenDirs { + if discovery.HasHiddenDirSkills(allSkills) { + fmt.Fprint(opts.IO.ErrOut, heredoc.Docf(` + %[1]s Skills in hidden directories (e.g. .claude/, .agents/) may be installed + copies from another publisher. Verify the skill's origin and check for a + canonical source. + `, cs.WarningIcon())) + } + return allSkills, nil + } + + var standard []discovery.Skill + var hiddenCount int + for _, s := range allSkills { + if s.IsHiddenDirConvention() { + hiddenCount++ + } else { + standard = append(standard, s) + } + } + + if len(standard) == 0 && hiddenCount > 0 { + return nil, fmt.Errorf( + "no standard skills found, but %d skill(s) exist in hidden directories\n"+ + " Use --allow-hidden-dirs to include them", + hiddenCount, + ) + } + + return standard, nil +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 120738fd052..c557c93e7fa 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -119,6 +119,11 @@ func TestNewCmdInstall(t *testing.T) { cli: "--from-local ./local-dir --pin v1.0.0", wantErr: true, }, + { + name: "allow-hidden-dirs flag", + cli: "monalisa/skills-repo --allow-hidden-dirs", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project", AllowHiddenDirs: true}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -157,6 +162,7 @@ func TestNewCmdInstall(t *testing.T) { assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) assert.Equal(t, tt.wantOpts.FromLocal, gotOpts.FromLocal) + assert.Equal(t, tt.wantOpts.AllowHiddenDirs, gotOpts.AllowHiddenDirs) if tt.wantLocalPath { assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set") } else { @@ -256,6 +262,14 @@ func singleSkillTreeJSON(name, treeSHA, blobSHA string) string { ) } +// hiddenDirSkillTreeJSON returns tree entries for a hidden-dir skill under .claude/skills/. +func hiddenDirSkillTreeJSON(name, treeSHA, blobSHA string) string { + return fmt.Sprintf( + `{"path": ".claude/skills/%s", "type": "tree", "sha": %q}, {"path": ".claude/skills/%s/SKILL.md", "type": "blob", "sha": %q}`, + name, treeSHA, name, blobSHA, + ) +} + func TestInstallRun(t *testing.T) { tests := []struct { name string @@ -1327,6 +1341,110 @@ func TestInstallRun(t *testing.T) { wantStdout: "Installed git-commit", wantStderr: "Installing to", }, + { + name: "hidden-dir skills excluded without --allow-hidden-dirs", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + hiddenDirSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + } + }, + wantErr: "no standard skills found, but 1 skill(s) exist in hidden directories", + }, + { + name: "hidden-dir skills included with --allow-hidden-dirs", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + hiddenDirSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + AllowHiddenDirs: true, + } + }, + wantStdout: "Installed git-commit", + wantStderr: "Skills in hidden directories", + }, + { + name: "mixed tree without --allow-hidden-dirs returns only standard", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")+", "+ + hiddenDirSkillTreeJSON("hidden-skill", "treeSHA2", "blobSHA2")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "mixed tree with --allow-hidden-dirs returns all", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")+", "+ + hiddenDirSkillTreeJSON("hidden-skill", "treeSHA2", "blobSHA2")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA2", "blobSHA2", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "hidden-skill", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + AllowHiddenDirs: true, + } + }, + wantStdout: "Installed hidden-skill", + wantStderr: "Skills in hidden directories", + }, } for _, tt := range tests { @@ -1853,6 +1971,67 @@ func TestRunLocalInstall(t *testing.T) { }, wantErr: "not found in local directory", }, + { + name: "local hidden-dir skills excluded without --allow-hidden-dirs", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join(".claude", "skills", "code-review"), heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "code-review", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "no standard skills found, but 1 skill(s) exist in hidden directories", + }, + { + name: "local hidden-dir skills included with --allow-hidden-dirs", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join(".claude", "skills", "code-review"), heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "code-review", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + AllowHiddenDirs: true, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed code-review", + wantStderr: "Skills in hidden directories", + }, } for _, tt := range tests { From 78f1ad537c034d31f43c29be0e76e7dc120b44df Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 17 Apr 2026 16:56:28 +0200 Subject: [PATCH 09/29] Include CI context in telemetry --- internal/ci/ci.go | 19 +++++++++++++ internal/ci/ci_test.go | 56 ++++++++++++++++++++++++++++++++++++++ internal/ghcmd/cmd.go | 9 ++++-- internal/update/update.go | 13 ++------- pkg/cmd/copilot/copilot.go | 4 +-- 5 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 internal/ci/ci.go create mode 100644 internal/ci/ci_test.go diff --git a/internal/ci/ci.go b/internal/ci/ci.go new file mode 100644 index 00000000000..6438127b093 --- /dev/null +++ b/internal/ci/ci.go @@ -0,0 +1,19 @@ +// Package ci provides helpers for detecting CI/CD execution environments. +package ci + +import "os" + +// IsCI determines if the current execution context is within a known CI/CD system. +// This is based on https://github.com/watson/ci-info/blob/HEAD/index.js. +func IsCI() bool { + return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari + os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity + os.Getenv("RUN_ID") != "" // TaskCluster, dsari +} + +// IsGitHubActions determines if the current execution context is within GitHub Actions. +// GitHub Actions sets the GITHUB_ACTIONS environment variable to "true" for all steps. +// See https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables. +func IsGitHubActions() bool { + return os.Getenv("GITHUB_ACTIONS") == "true" +} diff --git a/internal/ci/ci_test.go b/internal/ci/ci_test.go new file mode 100644 index 00000000000..6b2a28b54af --- /dev/null +++ b/internal/ci/ci_test.go @@ -0,0 +1,56 @@ +package ci + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsCI(t *testing.T) { + tests := []struct { + name string + env map[string]string + want bool + }{ + {name: "no CI env vars", env: map[string]string{}, want: false}, + {name: "CI set", env: map[string]string{"CI": "true"}, want: true}, + {name: "BUILD_NUMBER set", env: map[string]string{"BUILD_NUMBER": "42"}, want: true}, + {name: "RUN_ID set", env: map[string]string{"RUN_ID": "abc"}, want: true}, + {name: "CI empty string", env: map[string]string{"CI": ""}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("BUILD_NUMBER", "") + t.Setenv("RUN_ID", "") + for k, v := range tt.env { + t.Setenv(k, v) + } + assert.Equal(t, tt.want, IsCI()) + }) + } +} + +func TestIsGitHubActions(t *testing.T) { + tests := []struct { + name string + value string + set bool + want bool + }{ + {name: "unset", set: false, want: false}, + {name: "true", value: "true", set: true, want: true}, + {name: "false", value: "false", set: true, want: false}, + {name: "empty", value: "", set: true, want: false}, + {name: "other value", value: "yes", set: true, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "") + if tt.set { + t.Setenv("GITHUB_ACTIONS", tt.value) + } + assert.Equal(t, tt.want, IsGitHubActions()) + }) + } +} diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 7350437dfb4..34806f87449 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -19,6 +19,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/agents" "github.com/cli/cli/v2/internal/build" + "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/config/migration" "github.com/cli/cli/v2/internal/gh" @@ -69,9 +70,11 @@ func Main() exitCode { ghExecutablePath := executablePath("gh") additionalCommonDimensions := ghtelemetry.Dimensions{ - "version": strings.TrimPrefix(buildVersion, "v"), - "is_tty": strconv.FormatBool(ioStreams.IsStdoutTTY()), - "agent": string(agents.Detect()), + "version": strings.TrimPrefix(buildVersion, "v"), + "is_tty": strconv.FormatBool(ioStreams.IsStdoutTTY()), + "agent": string(agents.Detect()), + "ci": strconv.FormatBool(ci.IsCI()), + "github_actions": strconv.FormatBool(ci.IsGitHubActions()), } var telemetryService ghtelemetry.Service diff --git a/internal/update/update.go b/internal/update/update.go index a4a15ea17cc..20cd09606c8 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/pkg/extensions" "github.com/hashicorp/go-version" "github.com/mattn/go-isatty" @@ -42,7 +43,7 @@ func ShouldCheckForExtensionUpdate() bool { if os.Getenv("CODESPACES") != "" { return false } - return !IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) + return !ci.IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) } // CheckForExtensionUpdate checks whether an update exists for a specific extension based on extension type and recency of last check within past 24 hours. @@ -83,7 +84,7 @@ func ShouldCheckForUpdate() bool { if os.Getenv("CODESPACES") != "" { return false } - return !IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) + return !ci.IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) } // CheckForUpdate checks whether an update exists for the GitHub CLI based on recency of last check within past 24 hours. @@ -182,11 +183,3 @@ func versionGreaterThan(v, w string) bool { func IsTerminal(f *os.File) bool { return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } - -// IsCI determines if the current execution context is within a known CI/CD system. -// This is based on https://github.com/watson/ci-info/blob/HEAD/index.js. -func IsCI() bool { - return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari - os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity - os.Getenv("RUN_ID") != "" // TaskCluster, dsari -} diff --git a/pkg/cmd/copilot/copilot.go b/pkg/cmd/copilot/copilot.go index 4ab840709f1..1f2b7779858 100644 --- a/pkg/cmd/copilot/copilot.go +++ b/pkg/cmd/copilot/copilot.go @@ -18,10 +18,10 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safepaths" - "github.com/cli/cli/v2/internal/update" ghzip "github.com/cli/cli/v2/internal/zip" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -150,7 +150,7 @@ func runCopilot(opts *CopilotOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s Copilot CLI was not installed", opts.IO.ColorScheme().WarningIcon()) return cmdutil.SilentError } - } else if !update.IsCI() { + } else if !ci.IsCI() { fmt.Fprintf(opts.IO.ErrOut, "%s Copilot CLI not installed", opts.IO.ColorScheme().WarningIcon()) return cmdutil.SilentError } From 2e64043d55bb6b4ad6c543f95a1f781675155929 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 20 Apr 2026 12:22:55 +0200 Subject: [PATCH 10/29] fix(skills): stop publish --fix from publishing When --fix is used, return early after applying fixes instead of continuing to the publish flow. The fixed files need to be committed before publishing, so proceeding would fail anyway since the repo would not be in sync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish.go | 16 +++++++++++++--- pkg/cmd/skills/publish/publish_test.go | 1 + 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 6364846840f..73343f1716a 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -126,7 +126,8 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. Use %[1]s--dry-run%[1]s to validate without publishing. Use %[1]s--tag%[1]s to publish non-interactively with a specific tag. - Use %[1]s--fix%[1]s to automatically strip install metadata from committed files. + Use %[1]s--fix%[1]s to automatically strip install metadata from committed files + without publishing. Review and commit the changes, then run publish again. `, "`"), Example: heredoc.Doc(` # Validate and publish interactively @@ -138,7 +139,7 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. # Validate only (no publish) $ gh skill publish --dry-run - # Validate and strip install metadata + # Strip install metadata without publishing $ gh skills publish --fix `), Args: cobra.MaximumNArgs(1), @@ -153,7 +154,7 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. }, } - cmd.Flags().BoolVar(&opts.Fix, "fix", false, "Auto-fix issues where possible (e.g. strip install metadata)") + cmd.Flags().BoolVar(&opts.Fix, "fix", false, "Auto-fix issues where possible without publishing (e.g. strip install metadata)") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Validate without publishing") cmd.Flags().StringVar(&opts.Tag, "tag", "", "Version tag for the release (e.g. v1.0.0)") @@ -410,6 +411,15 @@ func publishRun(opts *PublishOptions) error { return nil } + if opts.Fix { + if fixes > 0 { + fmt.Fprintf(opts.IO.ErrOut, "\nFixed %d file(s). Review and commit the changes, then run %s to publish.\n", fixes, "gh skills publish") + } else { + fmt.Fprintf(opts.IO.ErrOut, "\nNo issues to fix.\n") + } + return nil + } + if owner == "" || repo == "" { fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed. Set up a GitHub remote to publish.\n") return nil diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index f83117b5b5e..2eac240bc65 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -457,6 +457,7 @@ func TestPublishRun(t *testing.T) { return &PublishOptions{IO: ios, Dir: dir, Fix: true} }, wantStdout: "stripped install metadata", + wantStderr: "Fixed 1 file(s). Review and commit the changes", verify: func(t *testing.T, dir string) { t.Helper() fixed, err := os.ReadFile(filepath.Join(dir, "skills", "test-skill", "SKILL.md")) From c703294fbe8022e256b0b3999901efc4d785621a Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 20 Apr 2026 12:34:59 +0200 Subject: [PATCH 11/29] fix(skills): use canonical 'gh skill' not 'gh skills' alias Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 73343f1716a..d7aeddd6503 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -140,7 +140,7 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. $ gh skill publish --dry-run # Strip install metadata without publishing - $ gh skills publish --fix + $ gh skill publish --fix `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -413,7 +413,7 @@ func publishRun(opts *PublishOptions) error { if opts.Fix { if fixes > 0 { - fmt.Fprintf(opts.IO.ErrOut, "\nFixed %d file(s). Review and commit the changes, then run %s to publish.\n", fixes, "gh skills publish") + fmt.Fprintf(opts.IO.ErrOut, "\nFixed %d file(s). Review and commit the changes, then run %s to publish.\n", fixes, "gh skill publish") } else { fmt.Fprintf(opts.IO.ErrOut, "\nNo issues to fix.\n") } From f88a2a671c72341aaed3646c803d06f89201efa9 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 20 Apr 2026 12:36:57 +0200 Subject: [PATCH 12/29] fix(skills): make --fix and --dry-run mutually exclusive, suppress publish prompt - --fix and --dry-run now error when combined - "Ready to publish!" is suppressed when --fix is set since user must commit first Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish.go | 5 ++++- pkg/cmd/skills/publish/publish_test.go | 14 ++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index d7aeddd6503..3a27fc5a2cd 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -147,6 +147,9 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. if len(args) == 1 { opts.Dir = args[0] } + if err := cmdutil.MutuallyExclusive("specify only one of `--fix` or `--dry-run`", opts.Fix, opts.DryRun); err != nil { + return err + } if runF != nil { return runF(opts) } @@ -1069,7 +1072,7 @@ func renderDiagnosticsTTY(opts *PublishOptions, skillCount int, diagnostics []pu fmt.Fprintf(opts.IO.ErrOut, "\n%s\n", d.message) } - if errors == 0 { + if errors == 0 && !opts.Fix { if owner != "" && repo != "" { fmt.Fprintf(opts.IO.ErrOut, "\n%s Repository: %s/%s\n", cs.Green("Ready to publish!"), owner, repo) } else { diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 2eac240bc65..a4f48dfe6e0 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -86,13 +86,15 @@ func TestNewCmdPublish(t *testing.T) { wantsOpts PublishOptions }{ { - name: "all flags", - cli: "./monalisa-skills --dry-run --fix --tag v1.0.0", + name: "fix and dry-run are mutually exclusive", + cli: "./monalisa-skills --dry-run --fix --tag v1.0.0", + wantsErr: true, + }, + { + name: "fix flag only", + cli: "--fix", wantsOpts: PublishOptions{ - Dir: "./monalisa-skills", - DryRun: true, - Fix: true, - Tag: "v1.0.0", + Fix: true, }, }, { From 9a368f45e9186482a8c511cadcef76d81f7c6bdd Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 17 Apr 2026 22:06:28 +0200 Subject: [PATCH 13/29] feat(skills): support nested skills/ directories in discovery Relax skill discovery to recognize skills/ directories at any depth in the repository tree, not just at the root. This enables repos like hashicorp/agent-skills that organize skills under prefixes such as terraform/code-generation/skills/*/SKILL.md. Changes: - matchSkillConventions: add checks for /skills//SKILL.md and /skills///SKILL.md at any depth - isSkillPath: also match paths containing /skills/ for direct path-based install - DiscoverSkillByPath: fix namespace detection to find the skills segment anywhere in the path - Update error messages and help text to mention nested conventions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/discovery/discovery.go | 76 +++++++- internal/skills/discovery/discovery_test.go | 191 +++++++++++++++++++- pkg/cmd/skills/install/install.go | 7 +- pkg/cmd/skills/install/install_test.go | 56 +++++- 4 files changed, 313 insertions(+), 17 deletions(-) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index b2c8baaed93..1b0c7f0075a 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -405,6 +405,24 @@ func matchSkillConventions(entry treeEntry) *skillMatch { return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "plugins"} } + // Deeply nested skills/ directory: /skills//SKILL.md + // Matches skills/ at any depth, not just at the repository root. + // Exclude paths with dot-prefixed segments (handled by + // matchHiddenDirConventions) and paths under a plugins/ directory + // (handled by the plugins convention above). + if path.Base(parentDir) == "skills" && !hasHiddenSegment(entry.Path) && !hasPluginsAncestor(entry.Path) { + return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "skills"} + } + + // Deeply nested namespaced: /skills///SKILL.md + if path.Base(grandparentDir) == "skills" && !hasHiddenSegment(entry.Path) && !hasPluginsAncestor(entry.Path) { + namespace := path.Base(parentDir) + if !validateName(namespace) { + return nil + } + return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "skills-namespaced"} + } + if parentDir == "." && skillName != "skills" && skillName != "plugins" && !strings.HasPrefix(skillName, ".") { return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "root"} } @@ -534,6 +552,7 @@ func DiscoverSkillsWithOptions(client *api.Client, host, owner, repo, commitSHA return nil, fmt.Errorf( "no skills found in %s/%s\n"+ " Expected skills in skills/*/SKILL.md, skills/{scope}/*/SKILL.md,\n"+ + " {prefix}/skills/*/SKILL.md, {prefix}/skills/{scope}/*/SKILL.md,\n"+ " */SKILL.md, or plugins/*/skills/*/SKILL.md\n"+ " This repository may be a curated list rather than a skills publisher", owner, repo, @@ -667,18 +686,35 @@ func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skill return nil, fmt.Errorf("no SKILL.md found in %s", skillPath) } - var namespace string + var namespace, convention string parts := strings.Split(skillPath, "/") - if len(parts) >= 3 && parts[0] == "skills" { - namespace = parts[1] + for i, p := range parts { + if p != "skills" { + continue + } + + // Plugin convention: .../plugins//skills/ + if i >= 2 && parts[i-2] == "plugins" { + namespace = parts[i-1] + convention = "plugins" + break + } + + // Namespaced skill convention: .../skills// + afterSkills := parts[i+1:] + if len(afterSkills) >= 2 { + namespace = afterSkills[0] + } + break } skill := &Skill{ - Name: skillName, - Namespace: namespace, - Path: skillPath, - BlobSHA: blobSHA, - TreeSHA: treeSHA, + Name: skillName, + Namespace: namespace, + Convention: convention, + Path: skillPath, + BlobSHA: blobSHA, + TreeSHA: treeSHA, } skill.Description = fetchDescription(client, host, owner, repo, skill) @@ -907,7 +943,9 @@ func DiscoverLocalSkillsWithOptions(dir string, opts DiscoverOptions) ([]Skill, return nil, fmt.Errorf( "no skills found in %s\n"+ " Expected SKILL.md in the directory, or skills in skills/*/SKILL.md,\n"+ - " skills/{scope}/*/SKILL.md, */SKILL.md, or plugins/*/skills/*/SKILL.md", + " skills/{scope}/*/SKILL.md, {prefix}/skills/*/SKILL.md,\n"+ + " {prefix}/skills/{scope}/*/SKILL.md, */SKILL.md, or\n"+ + " plugins/*/skills/*/SKILL.md", dir, ) } @@ -955,6 +993,26 @@ func validateName(name string) bool { return safeNamePattern.MatchString(name) } +// hasHiddenSegment reports whether any path component starts with a dot. +func hasHiddenSegment(p string) bool { + for _, seg := range strings.Split(p, "/") { + if strings.HasPrefix(seg, ".") { + return true + } + } + return false +} + +// hasPluginsAncestor reports whether any path component is "plugins". +func hasPluginsAncestor(p string) bool { + for _, seg := range strings.Split(p, "/") { + if seg == "plugins" { + return true + } + } + return false +} + // IsSpecCompliant checks if a skill name matches the strict agentskills.io spec. func IsSpecCompliant(name string) bool { if len(name) == 0 || len(name) > 64 { diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 41600f21c72..bc3ce979b54 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -100,6 +100,52 @@ func TestMatchSkillConventions(t *testing.T) { path: ".hidden/SKILL.md", wantNil: true, }, + { + name: "nested skills directory", + path: "terraform/code-generation/skills/terraform-style-guide/SKILL.md", + wantName: "terraform-style-guide", + wantConvention: "skills", + }, + { + name: "deeply nested skills directory", + path: "a/b/c/skills/my-skill/SKILL.md", + wantName: "my-skill", + wantConvention: "skills", + }, + { + name: "nested namespaced skills directory", + path: "terraform/code-generation/skills/hashicorp/terraform-style-guide/SKILL.md", + wantName: "terraform-style-guide", + wantNamespace: "hashicorp", + wantConvention: "skills-namespaced", + }, + { + name: "single prefix before skills directory", + path: "packer/skills/packer-builder/SKILL.md", + wantName: "packer-builder", + wantConvention: "skills", + }, + { + name: "root-level skills still has priority", + path: "skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "skills", + }, + { + name: "nested skills dir itself is not a skill", + path: "terraform/skills/SKILL.md", + wantNil: true, + }, + { + name: "nested skills under hidden dir excluded", + path: ".claude/skills/code-review/SKILL.md", + wantNil: true, + }, + { + name: "nested plugins skills not matched as plain skills", + path: "vendor/plugins/hubot/skills/pr-summary/SKILL.md", + wantNil: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -865,6 +911,41 @@ func TestDiscoverSkills(t *testing.T) { }, wantSkills: []string{"code-review"}, }, + { + name: "discovers skills in nested skills directory", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "terraform/code-generation/skills/terraform-style-guide", "type": "tree", "sha": "tree-sha-1"}, + {"path": "terraform/code-generation/skills/terraform-style-guide/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "terraform/code-generation/skills/terraform-test", "type": "tree", "sha": "tree-sha-2"}, + {"path": "terraform/code-generation/skills/terraform-test/SKILL.md", "type": "blob", "sha": "blob-2"}, + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantSkills: []string{"terraform-style-guide", "terraform-test"}, + }, + { + name: "discovers mixed root-level and nested skills", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/code-review", "type": "tree", "sha": "tree-sha-1"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "terraform/skills/tf-lint", "type": "tree", "sha": "tree-sha-2"}, + {"path": "terraform/skills/tf-lint/SKILL.md", "type": "blob", "sha": "blob-2"}, + }, + })) + }, + wantSkills: []string{"code-review", "tf-lint"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -967,12 +1048,13 @@ func TestDiscoverSkillsWithOptions(t *testing.T) { func TestDiscoverSkillByPath(t *testing.T) { tests := []struct { - name string - skillPath string - stubs func(*httpmock.Registry) - wantName string - wantNS string - wantErr string + name string + skillPath string + stubs func(*httpmock.Registry) + wantName string + wantNS string + wantConvention string + wantErr string }{ { name: "discovers skill by path", @@ -1112,6 +1194,84 @@ func TestDiscoverSkillByPath(t *testing.T) { }, wantErr: "no SKILL.md found", }, + { + name: "deeply nested path discovers skill", + skillPath: "terraform/code-generation/skills/terraform-style-guide", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/terraform%2Fcode-generation%2Fskills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "terraform-style-guide", "path": "terraform/code-generation/skills/terraform-style-guide", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "terraform-style-guide", + }, + { + name: "deeply nested namespaced path sets namespace", + skillPath: "terraform/code-generation/skills/hashicorp/terraform-style-guide", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/terraform%2Fcode-generation%2Fskills%2Fhashicorp"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "terraform-style-guide", "path": "terraform/code-generation/skills/hashicorp/terraform-style-guide", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "terraform-style-guide", + wantNS: "hashicorp", + }, + { + name: "plugins path sets namespace and convention", + skillPath: "plugins/hubot/skills/pr-summary", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/plugins%2Fhubot%2Fskills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "pr-summary", "path": "plugins/hubot/skills/pr-summary", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "pr-summary", + wantNS: "hubot", + wantConvention: "plugins", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1131,6 +1291,9 @@ func TestDiscoverSkillByPath(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.wantName, skill.Name) assert.Equal(t, tt.wantNS, skill.Namespace) + if tt.wantConvention != "" { + assert.Equal(t, tt.wantConvention, skill.Convention) + } }) } } @@ -1184,6 +1347,19 @@ func TestDiscoverLocalSkills(t *testing.T) { setup: func(t *testing.T, dir string) {}, wantErr: "could not access", }, + { + name: "discovers skills in nested skills/ directory", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + for _, name := range []string{"terraform-style-guide", "terraform-test"} { + skillDir := filepath.Join(dir, "terraform", "code-generation", "skills", name) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + wantSkills: []string{"terraform-style-guide", "terraform-test"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1278,6 +1454,7 @@ func TestMatchesSkillPath(t *testing.T) { {name: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary"}, {name: "non-skill file", path: "README.md", wantName: ""}, {name: "non-SKILL.md in skill dir", path: "skills/code-review/prompt.txt", wantName: ""}, + {name: "nested skills convention", path: "terraform/code-generation/skills/terraform-style-guide/SKILL.md", wantName: "terraform-style-guide"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1300,6 +1477,8 @@ func TestMatchSkillPath(t *testing.T) { {name: "same name different namespace 1", path: "skills/kynan/commit/SKILL.md", wantName: "commit", wantNamespace: "kynan"}, {name: "same name different namespace 2", path: "skills/will/commit/SKILL.md", wantName: "commit", wantNamespace: "will"}, {name: "root convention", path: "my-skill/SKILL.md", wantName: "my-skill", wantNamespace: ""}, + {name: "nested skills convention", path: "terraform/code-generation/skills/terraform-style-guide/SKILL.md", wantName: "terraform-style-guide", wantNamespace: ""}, + {name: "nested namespaced convention", path: "terraform/code-generation/skills/hashicorp/terraform-style-guide/SKILL.md", wantName: "terraform-style-guide", wantNamespace: "hashicorp"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 88cd2673163..d792501fd27 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -107,7 +107,9 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru tracking metadata injected into frontmatter. Skills are discovered automatically using the %[1]sskills/*/SKILL.md%[1]s convention - defined by the Agent Skills specification. For more information on the specification, + defined by the Agent Skills specification, including when the %[1]sskills/%[1]s + directory is nested under a prefix (e.g. %[1]sterraform/code-generation/skills/...%[1]s). + For more information on the specification, see: https://agentskills.io/specification The skill argument can be a name, a namespaced name (%[1]sauthor/skill%[1]s), @@ -504,6 +506,9 @@ func isSkillPath(name string) bool { if strings.HasPrefix(name, "skills/") || strings.HasPrefix(name, "plugins/") { return true } + if strings.Contains(name, "/skills/") || strings.Contains(name, "/plugins/") { + return true + } return false } diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index c557c93e7fa..e4bd074bd5f 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "fmt" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -231,7 +232,7 @@ func stubSkillByPath(reg *httpmock.Registry, owner, repo, sha, skillPath, skillN parentPath = skillPath[:idx] } reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, parentPath)), + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, url.PathEscape(parentPath))), httpmock.StringResponse(fmt.Sprintf(`[{"name": %q, "path": %q, "sha": %q, "type": "dir"}]`, skillName, skillPath, treeSHA)), ) } @@ -759,6 +760,34 @@ func TestInstallRun(t *testing.T) { }, wantStdout: "Installed git-commit", }, + { + name: "remote install by nested skill path skips full discovery", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubSkillByPath(reg, "monalisa", "skills-repo", "abc123", + "terraform/code-generation/skills/terraform-style-guide", "terraform-style-guide", "treeSHA") + // DiscoverSkillByPath: tree + blob (for fetchDescription) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + // installer.Install: tree + blob (again, for writing files) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "terraform/code-generation/skills/terraform-style-guide", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed terraform-style-guide", + }, { name: "remote install with URL repo argument", isTTY: true, @@ -2075,6 +2104,31 @@ func TestRunLocalInstall(t *testing.T) { } } +func Test_isSkillPath(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + {name: "empty string", path: "", want: false}, + {name: "plain skill name", path: "git-commit", want: false}, + {name: "SKILL.md at root", path: "SKILL.md", want: true}, + {name: "SKILL.md suffix", path: "skills/code-review/SKILL.md", want: true}, + {name: "starts with skills/", path: "skills/code-review", want: true}, + {name: "starts with plugins/", path: "plugins/hubot/skills/pr-summary", want: true}, + {name: "nested skills/ path", path: "terraform/code-generation/skills/terraform-style-guide", want: true}, + {name: "deeply nested skills/ path", path: "a/b/c/skills/my-skill", want: true}, + {name: "nested plugins/ path", path: "vendor/plugins/hubot/skills/pr-summary", want: true}, + {name: "name containing skills substring", path: "myskills", want: false}, + {name: "namespaced path", path: "skills/monalisa/issue-triage", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isSkillPath(tt.path)) + }) + } +} + func Test_printReviewHint(t *testing.T) { tests := []struct { name string From 33789149b9def6b66ddf247438a79fac94e22074 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:38:59 -0600 Subject: [PATCH 14/29] docs(skills): add gh and gh-skill agent skills Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- skills/gh-skill/SKILL.md | 124 +++++++++++++++++++++++++++++++++++++++ skills/gh/SKILL.md | 75 +++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 skills/gh-skill/SKILL.md create mode 100644 skills/gh/SKILL.md diff --git a/skills/gh-skill/SKILL.md b/skills/gh-skill/SKILL.md new file mode 100644 index 00000000000..59f58d325d9 --- /dev/null +++ b/skills/gh-skill/SKILL.md @@ -0,0 +1,124 @@ +--- +name: gh-skill +description: Manage agent skills with gh skill. Use this skill to discover, preview, install, update, and publish Agent Skills so an agent can self-manage the skills available in its environment. +--- + +# Managing skills with `gh skill` + +`gh skill` installs, previews, searches, updates, and publishes +[Agent Skills](https://agentskills.io). An agent can use it to keep its +own skill set in sync with one or more GitHub repositories. + +The command is also aliased as `gh skills`. Prefer the canonical singular +`gh skill` in scripts and docs. + +## Search + +```bash +gh skill search # free-text search +gh skill search --owner # restrict to one owner +gh skill search --limit 20 --page 2 +gh skill search --json skillName,repo,description +``` + +## Preview before installing + +```bash +gh skill preview / +gh skill preview / @v1.2.0 # pin a version +``` + +## Install + +```bash +gh skill install / +gh skill install / @v1.2.0 +gh skill install / skills// # exact path, fastest +gh skill install ./local-skills-repo --from-local +``` + +`/` and `` are both required. + +Useful flags: + +- `--agent ` - target host (e.g. `github-copilot`, `claude-code`, + `cursor`, `codex`, `gemini-cli`). Repeat for multiple. Default is + `github-copilot` when non-interactive. You should know what agent you are, + so set this appropriately to install for yourself. +- `--scope project|user` - `project` (default) writes inside the current + git repo; `user` writes to the home directory and applies everywhere. +- `--pin ` - pin to a tag, branch, or commit SHA. Mutually exclusive + with `--from-local` and with inline `@version` syntax. +- `--allow-hidden-dirs` - also discover skills under dot-directories such + as `.claude/skills/`. Don't use this unless you need to, it comes with risks. +- `--force` - overwrite an existing install. + +## Update + +```bash +gh skill update --all # update every installed skill +gh skill update # update one +gh skill update --force +gh skill update --unpin # drop the pin and move to latest +``` + +## Publish + +Publishing turns a repo into a discoverable skill source. Skills are +discovered with these conventions: + +- `skills//SKILL.md` +- `skills///SKILL.md` +- `/SKILL.md` (root-level) +- `plugins//skills//SKILL.md` + +Each `SKILL.md` needs YAML frontmatter: + +```yaml +--- +name: my-skill # must equal the directory name +description: One sentence... # required, recommended <= 1024 chars +license: MIT # optional but recommended +--- +``` + +Naming rules (enforced by `publish`): + +- Lowercase alphanumeric and hyphens only, 1–64 chars. +- Must start and end with `[a-z0-9]`. +- No double hyphens (`--`). +- `allowed-tools`, if present, must be a string (space-delimited), not a + YAML array. + +### Validate, then publish + +```bash +gh skill publish --dry-run # validate only, no release +gh skill publish --dry-run ./path/to/repo # validate a specific dir +gh skill publish --fix # auto-strip install metadata +gh skill publish --tag v1.0.0 # non-interactive publish +gh skill publish # interactive publish flow +``` + +`--fix` and `--dry-run` are mutually exclusive. `--fix` only rewrites +install-injected `metadata.github-*` keys and does not publish; commit +the result and re-run `publish`. + +The publish flow will: + +1. Add the `agent-skills` topic to the repo (so search can find it). +2. Use `--tag` (or prompt for one in a TTY). +3. Auto-push any unpushed commits. +4. Create a GitHub release with auto-generated notes. + +Always pass `--tag` so it doesn't fall through to the interactive flow. + +## Self-management pattern for agents + +A reasonable loop: + +1. `gh skill search --json name,repository` +2. `gh skill preview ` to inspect the SKILL.md. +3. `gh skill install --agent --pin ` for a + reproducible install. +4. Periodically `gh skill update --all` to refresh. \ No newline at end of file diff --git a/skills/gh/SKILL.md b/skills/gh/SKILL.md new file mode 100644 index 00000000000..e37dafdbab3 --- /dev/null +++ b/skills/gh/SKILL.md @@ -0,0 +1,75 @@ +--- +name: gh +description: Patterns for invoking the GitHub CLI (gh) from agents. Covers structured output, pagination, repo targeting, search vs list, gh api fallback. +--- + +# Reference + +## Interactivity policy + +`gh` already does the right thing in non-TTY contexts: it skips the pager, +strips ANSI color, and errors out fast with a helpful message instead of +prompting (e.g. `must provide --title and --body when not running interactively`). +You don't need to defensively set `GH_PAGER` or pass `--no-pager` (no such +flag exists). + +## Parsing JSON + +Human output from `gh` is column-formatted. If you want structured data: + +- Add `--json field1,field2,...` for structured output. +- Run a command with `--json` and **no field list** to print the full set of + available fields, then pick what you need. +- Use `--jq ''` for filtering without piping through a separate `jq`. +- Use `--template ''` when you want shaped text output. + +## Pagination and silent truncation + +List commands cap results. + +- `gh issue list`, `gh pr list`, `gh search ...`: pass `-L N` (`--limit N`). + The default is usually 30. +- Use `--json totalCount` to get the total number of items. This helps you know + if you need to paginate. +- For raw API calls use `gh api --paginate `. Combine with + `--jq` and (optionally) `--slurp` to assemble one array. + +## Repo targeting + +`gh` infers the repo from the cwd's git remotes. + +Pass `--repo OWNER/REPO` (`-R`) to override the resolved CWD repo. + +## Search vs list + +- `gh search issues|prs|code|repos|commits|users` uses GitHub's search + index and accepts the full search syntax (`is:open`, `author:`, + `label:`, `repo:owner/name`, `in:title`, ...). Prefer it for anything + cross-repo or filtered by author/label. +- `gh issue list --search "..."` and `gh pr list --search "..."` accept + the same syntax but are scoped to one repo. + +## Fall back to `gh api` for anything `--json` doesn't expose + +Sometimes useful data isn't on the typed commands. Examples: + +- Review-thread comments on a PR: `gh api repos/{owner}/{repo}/pulls/{n}/comments` + (the `--comments` flag on `gh pr view` shows issue-level comments only). +- Arbitrary GraphQL: `gh api graphql -f query='...' -F var=value`. +- REST shortcuts: `gh api repos/{owner}/{repo}/...` - note the + `{owner}/{repo}` placeholder is filled in for you when run from a repo + with detected remotes; pass them literally if you want determinism. + +## Authentication + +- `gh auth status` prints the active host(s), user, and which env var (if + any) is being honored. +- `gh auth status --json` is supported. + +## Other notes + +- `gh pr checkout ` switches branches. Use `gh pr diff ` or + `gh pr view ` if you only need to read. +- `NO_COLOR`, `CLICOLOR_FORCE`, and `GH_FORCE_TTY` are honored. Set + `GH_FORCE_TTY=1` if you want TTY-style output (colors, tables, the + pager, interactivity) inside an agent harness; leave it unset unless needed. From 991e37dc2550b421c8daed7cd405d4f0859931e9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:07:56 -0600 Subject: [PATCH 15/29] use hyphen instead --- skills/gh-skill/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/gh-skill/SKILL.md b/skills/gh-skill/SKILL.md index 59f58d325d9..319b3ce4c3b 100644 --- a/skills/gh-skill/SKILL.md +++ b/skills/gh-skill/SKILL.md @@ -84,7 +84,7 @@ license: MIT # optional but recommended Naming rules (enforced by `publish`): -- Lowercase alphanumeric and hyphens only, 1–64 chars. +- Lowercase alphanumeric and hyphens only, 1-64 chars. - Must start and end with `[a-z0-9]`. - No double hyphens (`--`). - `allowed-tools`, if present, must be a string (space-delimited), not a From 01ca82955bc3ebd7f2a282cee1d6630973d0a2e7 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:12:27 -0600 Subject: [PATCH 16/29] docs(skills): drop hand-copied naming rules from gh-skill Avoids drift between this skill and the actual validation logic in publish. Agents should run `gh skill publish --dry-run` to learn the current rules from the source of truth. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- skills/gh-skill/SKILL.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/skills/gh-skill/SKILL.md b/skills/gh-skill/SKILL.md index 319b3ce4c3b..c32c5ced9fa 100644 --- a/skills/gh-skill/SKILL.md +++ b/skills/gh-skill/SKILL.md @@ -82,14 +82,6 @@ license: MIT # optional but recommended --- ``` -Naming rules (enforced by `publish`): - -- Lowercase alphanumeric and hyphens only, 1-64 chars. -- Must start and end with `[a-z0-9]`. -- No double hyphens (`--`). -- `allowed-tools`, if present, must be a string (space-delimited), not a - YAML array. - ### Validate, then publish ```bash From 72884d9e415d51170ebb6f403c062a57bb5f2dfe Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:14:07 -0600 Subject: [PATCH 17/29] backtick skill filename Co-authored-by: Babak K. Shandiz --- skills/gh-skill/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/gh-skill/SKILL.md b/skills/gh-skill/SKILL.md index c32c5ced9fa..cd505bf2934 100644 --- a/skills/gh-skill/SKILL.md +++ b/skills/gh-skill/SKILL.md @@ -110,7 +110,7 @@ Always pass `--tag` so it doesn't fall through to the interactive flow. A reasonable loop: 1. `gh skill search --json name,repository` -2. `gh skill preview ` to inspect the SKILL.md. +2. `gh skill preview ` to inspect the `SKILL.md`. 3. `gh skill install --agent --pin ` for a reproducible install. 4. Periodically `gh skill update --all` to refresh. \ No newline at end of file From 65974f568f07e05baee448cc7adfad936ae0a5aa Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:18:40 -0600 Subject: [PATCH 18/29] docs(skills): note --template collisions and single-string search Addresses review feedback on PR #13244: - Flag that --template/-T collides with body-template flags on pr create / issue create, so agents should check --help. - Recommend the single quoted-string form for gh search (matching --search), since multi-arg invocations join oddly and can produce invalid queries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- skills/gh/SKILL.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/skills/gh/SKILL.md b/skills/gh/SKILL.md index e37dafdbab3..366978d1597 100644 --- a/skills/gh/SKILL.md +++ b/skills/gh/SKILL.md @@ -21,7 +21,10 @@ Human output from `gh` is column-formatted. If you want structured data: - Run a command with `--json` and **no field list** to print the full set of available fields, then pick what you need. - Use `--jq ''` for filtering without piping through a separate `jq`. -- Use `--template ''` when you want shaped text output. +- Use `--template ''` (alongside `--json`) when you want shaped + text output. Note that `--template`/`-T` collides with a body-template flag + on a few commands (e.g. `gh pr create -T`, `gh issue create -T`); always + check `--help` before assuming which one you're hitting. ## Pagination and silent truncation @@ -44,8 +47,10 @@ Pass `--repo OWNER/REPO` (`-R`) to override the resolved CWD repo. - `gh search issues|prs|code|repos|commits|users` uses GitHub's search index and accepts the full search syntax (`is:open`, `author:`, - `label:`, `repo:owner/name`, `in:title`, ...). Prefer it for anything - cross-repo or filtered by author/label. + `label:`, `repo:owner/name`, `in:title`, ...). Pass the entire query as + one quoted string, the same way you would for `--search`: + `gh search issues "is:open author:foo repo:cli/cli"`. Prefer it for + anything cross-repo or filtered by author/label. - `gh issue list --search "..."` and `gh pr list --search "..."` accept the same syntax but are scoped to one repo. From 01e345082cd816e2485cf88aea0f3b731dc5d09e Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:21:26 -0600 Subject: [PATCH 19/29] fix totalCount guidance Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- skills/gh/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skills/gh/SKILL.md b/skills/gh/SKILL.md index 366978d1597..34cce8bc2d8 100644 --- a/skills/gh/SKILL.md +++ b/skills/gh/SKILL.md @@ -32,8 +32,9 @@ List commands cap results. - `gh issue list`, `gh pr list`, `gh search ...`: pass `-L N` (`--limit N`). The default is usually 30. -- Use `--json totalCount` to get the total number of items. This helps you know - if you need to paginate. +- `gh issue list` / `gh pr list` do not expose aggregate totals like + `totalCount` via `--json`. If you need a true total, use `gh api graphql` + to query `totalCount`; otherwise, treat `-L` as the cap for the current call. - For raw API calls use `gh api --paginate `. Combine with `--jq` and (optionally) `--slurp` to assemble one array. From 74d377313a570ee84232389bee5d6be952ae1410 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:22:08 -0600 Subject: [PATCH 20/29] use the right json field names for skills Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- skills/gh-skill/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/gh-skill/SKILL.md b/skills/gh-skill/SKILL.md index cd505bf2934..e0db2feaed4 100644 --- a/skills/gh-skill/SKILL.md +++ b/skills/gh-skill/SKILL.md @@ -109,7 +109,7 @@ Always pass `--tag` so it doesn't fall through to the interactive flow. A reasonable loop: -1. `gh skill search --json name,repository` +1. `gh skill search --json skillName,repo,namespace` 2. `gh skill preview ` to inspect the `SKILL.md`. 3. `gh skill install --agent --pin ` for a reproducible install. From 1160943af343d9904356db0d9f4bb2f5b5a9b651 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 21 Apr 2026 09:56:27 +0200 Subject: [PATCH 21/29] fix(skills): match skills by install name in preview command The preview command's selectSkill function only matched skills by DisplayName() and Name. For plugins-convention skills, the install hint outputs InstallName() (namespace/name), which matched neither - DisplayName() includes a [plugins] prefix and Name is just the base name. This caused 'skill not found' errors when users ran the suggested preview command after install. Add InstallName() as an additional match criterion so that namespaced skill identifiers produced by the install hint are accepted. Closes #13248 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/preview/preview.go | 2 +- pkg/cmd/skills/preview/preview_test.go | 45 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index e9f1e0442ce..98762fb119b 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -391,7 +391,7 @@ func isMarkdownFile(filePath string) bool { func selectSkill(opts *PreviewOptions, skills []discovery.Skill) (discovery.Skill, error) { if opts.SkillName != "" { for _, s := range skills { - if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { + if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName || s.InstallName() == opts.SkillName { return s, nil } } diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index a5d5554ffe9..be3c861170e 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -206,6 +206,51 @@ func TestPreviewRun(t *testing.T) { }, wantStdout: "My Skill", }, + { + name: "preview plugins skill matched by install name", + tty: true, + opts: &PreviewOptions{ + repo: ghrepo.New("owner", "repo"), + SkillName: "aws-common/aws-mcp-setup", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "plugins", "type": "tree", "sha": "tree-plugins"}, + {"path": "plugins/aws-common", "type": "tree", "sha": "tree-awscommon"}, + {"path": "plugins/aws-common/skills", "type": "tree", "sha": "tree-awsskills"}, + {"path": "plugins/aws-common/skills/aws-mcp-setup", "type": "tree", "sha": "treeSHA3"}, + {"path": "plugins/aws-common/skills/aws-mcp-setup/SKILL.md", "type": "blob", "sha": "blob789"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA3"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob789", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob789"), + httpmock.StringResponse(`{"sha": "blob789", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, { name: "skill not found", tty: true, From 6fcc9c24df4e0307a89be32d6ebaa0002d4545e3 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 21 Apr 2026 10:09:20 +0200 Subject: [PATCH 22/29] fix(skills): prioritize DisplayName/Name over InstallName match Use a two-pass search so exact DisplayName and Name matches are preferred over InstallName. This avoids incorrectly selecting a plugins-convention skill via InstallName when a standard namespaced skill with a matching DisplayName exists later in the sorted slice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/preview/preview.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 98762fb119b..1c9d4d91329 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -391,7 +391,14 @@ func isMarkdownFile(filePath string) bool { func selectSkill(opts *PreviewOptions, skills []discovery.Skill) (discovery.Skill, error) { if opts.SkillName != "" { for _, s := range skills { - if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName || s.InstallName() == opts.SkillName { + if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { + return s, nil + } + } + // Fall back to InstallName so that namespaced identifiers produced + // by the post-install hint (e.g. "namespace/skill") are accepted. + for _, s := range skills { + if s.InstallName() == opts.SkillName { return s, nil } } From c50fb793eadeedfe7a2449ccd37afa55911aef6d Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 17 Apr 2026 14:43:20 +0200 Subject: [PATCH 23/29] Record official extension telemetry --- .../no-telemetry-for-extension.txtar | 4 +- ...elemetry-for-official-extension-stub.txtar | 20 ++++++ pkg/cmd/root/extension.go | 9 ++- pkg/cmd/root/extension_registration_test.go | 3 + pkg/cmd/root/extension_test.go | 63 +++++++++++++++++++ pkg/extensions/official.go | 27 ++++++++ pkg/extensions/official_test.go | 58 +++++++++++++++++ 7 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar diff --git a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar index b87b3e252ed..fcb820e0c2a 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar @@ -1,4 +1,6 @@ -# Extensions should not generate telemetry events +# Third-party extensions must not generate telemetry events, since the +# extension command name can be a user-authored identifier (e.g. an +# organization or project name). [!exec:bash] skip env GH_PRIVATE_ENABLE_TELEMETRY=1 diff --git a/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar new file mode 100644 index 00000000000..c64739af45d --- /dev/null +++ b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar @@ -0,0 +1,20 @@ +# Official extension stubs (the hidden commands suggesting installation of +# GitHub-owned extensions) are safe to report via telemetry: their command +# names come from a fixed, hard-coded registry and do not contain any +# user-authored identifiers. + +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +# `stack` is registered in extensions.OfficialExtensions. Since no real +# extension is installed, the hidden stub runs and, in a non-TTY session, +# prints install instructions without prompting. +exec gh stack +stderr 'gh extension install github/gh-stack' + +# The stub invocation records a command_invocation event for the stub's +# command path. +stderr 'Telemetry payload:' +stderr '"type": "command_invocation"' +stderr '"command": "gh stack"' diff --git a/pkg/cmd/root/extension.go b/pkg/cmd/root/extension.go index 45a60332b8d..c432290aa49 100644 --- a/pkg/cmd/root/extension.go +++ b/pkg/cmd/root/extension.go @@ -79,7 +79,14 @@ func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ex } cmdutil.DisableAuthCheck(cmd) - cmdutil.DisableTelemetry(cmd) + // Extensions are user-installed and their names can be arbitrary + // (potentially including sensitive identifiers such as project or + // organization names), so we must not record telemetry for them by + // default. Official GitHub-owned extensions are a known, fixed set and + // can safely contribute their command name to telemetry. + if !extensions.IsOfficial(ext.Name(), ext.Owner()) { + cmdutil.DisableTelemetry(cmd) + } return cmd } diff --git a/pkg/cmd/root/extension_registration_test.go b/pkg/cmd/root/extension_registration_test.go index 828f51ca066..61d73b1b9b9 100644 --- a/pkg/cmd/root/extension_registration_test.go +++ b/pkg/cmd/root/extension_registration_test.go @@ -57,6 +57,9 @@ func TestNewCmdRoot_ExtensionRegistration(t *testing.T) { NameFunc: func() string { return extName }, + OwnerFunc: func() string { + return "" + }, }) } diff --git a/pkg/cmd/root/extension_test.go b/pkg/cmd/root/extension_test.go index 5e9e9b9bcf5..9bb5037a593 100644 --- a/pkg/cmd/root/extension_test.go +++ b/pkg/cmd/root/extension_test.go @@ -144,6 +144,9 @@ func TestNewCmdExtension_Updates(t *testing.T) { NameFunc: func() string { return tt.extName }, + OwnerFunc: func() string { + return "" + }, UpdateAvailableFunc: func() bool { return tt.extUpdateAvailable }, @@ -199,6 +202,9 @@ func TestNewCmdExtension_UpdateCheckIsNonblocking(t *testing.T) { NameFunc: func() string { return "major-update" }, + OwnerFunc: func() string { + return "" + }, UpdateAvailableFunc: func() bool { return true }, @@ -234,3 +240,60 @@ func TestNewCmdExtension_UpdateCheckIsNonblocking(t *testing.T) { t.Fatal("extension update check should have exited") } } + +func TestNewCmdExtension_TelemetryEnabledForOfficialExtensions(t *testing.T) { + tests := []struct { + name string + extName string + extOwner string + wantTelemetryOff bool + }{ + { + name: "official extension records telemetry", + extName: "stack", + extOwner: "github", + wantTelemetryOff: false, + }, + { + name: "official name with third-party owner disables telemetry", + extName: "stack", + extOwner: "williammartin", + wantTelemetryOff: true, + }, + { + name: "official name with empty owner disables telemetry", + extName: "stack", + extOwner: "", + wantTelemetryOff: true, + }, + { + name: "official extension name with mixed case disables telemetry", + extName: "STACK", + extOwner: "github", + wantTelemetryOff: true, + }, + { + name: "third-party extension disables telemetry", + extName: "my-custom-ext", + extOwner: "someone", + wantTelemetryOff: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + em := &extensions.ExtensionManagerMock{} + ext := &extensions.ExtensionMock{ + NameFunc: func() string { return tt.extName }, + OwnerFunc: func() string { return tt.extOwner }, + } + + cmd := root.NewCmdExtension(ios, em, ext, func(extensions.ExtensionManager, extensions.Extension) (*update.ReleaseInfo, error) { + return nil, nil + }) + + assert.Equal(t, tt.wantTelemetryOff, cmd.Annotations["telemetry"] == "disabled") + }) + } +} diff --git a/pkg/extensions/official.go b/pkg/extensions/official.go index a07c426df91..dc6bdc919b0 100644 --- a/pkg/extensions/official.go +++ b/pkg/extensions/official.go @@ -1,6 +1,8 @@ package extensions import ( + "strings" + "github.com/cli/cli/v2/internal/ghrepo" ) @@ -24,3 +26,28 @@ var OfficialExtensions = []OfficialExtension{ {Name: "aw", Owner: "github", Repo: "gh-aw"}, {Name: "stack", Owner: "github", Repo: "gh-stack"}, } + +// IsOfficial reports whether the given extension command name and owner +// match an entry in the OfficialExtensions registry. Owner must be +// checked alongside name because a user may have installed a third-party +// extension that happens to share a name with one of ours (e.g. +// `someuser/gh-stack` predates `github/gh-stack` becoming official). +// Owner will be empty for local extensions, in which case the extension +// is treated as non-official. +// +// Comparison is case-sensitive: on case-sensitive filesystems a user can +// install a private extension whose name differs only in casing (e.g. +// `gh-STACK`), and we must not treat that as official. Owner comparison +// is case-insensitive because GitHub usernames and organization names +// are themselves case-insensitive. +func IsOfficial(name, owner string) bool { + if owner == "" { + return false + } + for _, ext := range OfficialExtensions { + if ext.Name == name && strings.EqualFold(ext.Owner, owner) { + return true + } + } + return false +} diff --git a/pkg/extensions/official_test.go b/pkg/extensions/official_test.go index 047af580af1..6d16ece2cf9 100644 --- a/pkg/extensions/official_test.go +++ b/pkg/extensions/official_test.go @@ -13,3 +13,61 @@ func TestOfficialExtension_Repository(t *testing.T) { assert.Equal(t, "gh-stack", repo.RepoName()) assert.Equal(t, "github.com", repo.RepoHost()) } + +func TestIsOfficial(t *testing.T) { + tests := []struct { + name string + extName string + extOwner string + want bool + }{ + { + name: "known official extension matches", + extName: "stack", + extOwner: "github", + want: true, + }, + { + name: "official name with different owner is not official", + extName: "stack", + extOwner: "williammartin", + want: false, + }, + { + name: "official name with empty owner is not official", + extName: "stack", + extOwner: "", + want: false, + }, + { + name: "owner comparison is case-insensitive", + extName: "stack", + extOwner: "GitHub", + want: true, + }, + { + name: "mixed-case name does not match", + extName: "STACK", + extOwner: "github", + want: false, + }, + { + name: "unknown name is not official", + extName: "not-a-real-extension", + extOwner: "github", + want: false, + }, + { + name: "empty name is not official", + extName: "", + extOwner: "github", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsOfficial(tt.extName, tt.extOwner)) + }) + } +} From 6b811db4671c9e4d87bc48ab3a249218aa52c879 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 21 Apr 2026 17:12:46 +0200 Subject: [PATCH 24/29] Add telemetry command --- pkg/cmd/root/help_topic.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index 3002a351294..0becbf5c81b 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -127,6 +127,16 @@ var HelpTopics = []helpTopic{ a textual progress indicator. `, "`"), }, + { + name: "telemetry", + short: "Information about telemetry in gh", + long: heredoc.Doc(` + gh collects telemetry to help us understand how the CLI is being used and to improve it. + + To learn more about what data is collected, how it is used, and how to opt out, see: + + `), + }, { name: "reference", short: "A comprehensive reference of all gh commands", From 571bb1c923f9487df7828d75e5b0f424f701c0fc Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 21 Apr 2026 18:15:02 +0200 Subject: [PATCH 25/29] Log when there is no telemetry --- .../no-telemetry-for-completion.txtar | 2 +- .../no-telemetry-for-extension.txtar | 2 +- .../no-telemetry-for-ghes-user.txtar | 2 +- .../no-telemetry-for-send-telemetry.txtar | 2 +- internal/ghcmd/cmd.go | 16 ++++- internal/telemetry/telemetry.go | 38 +++++++----- internal/telemetry/telemetry_test.go | 58 +++++++++++++++---- 7 files changed, 90 insertions(+), 30 deletions(-) diff --git a/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar index ffde6e6059f..20139ce5f41 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar @@ -4,4 +4,4 @@ env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 exec gh completion -s bash -! stderr 'Telemetry payload:' +stderr 'Telemetry payload: none' diff --git a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar index fcb820e0c2a..19f3d69ccaf 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar @@ -22,7 +22,7 @@ cd $WORK # Run the extension and verify no telemetry is logged exec gh hello stdout 'hello from extension' -! stderr 'Telemetry payload:' +stderr 'Telemetry payload: none' -- gh-hello.sh -- #!/usr/bin/env bash diff --git a/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar index 0fe6f4bb210..f04fabf364e 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar @@ -5,4 +5,4 @@ env GH_TELEMETRY_SAMPLE_RATE=100 env GH_ENTERPRISE_TOKEN=fake-enterprise-token exec gh version -! stderr 'Telemetry payload:' +stderr 'Telemetry payload: none' diff --git a/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar index 7f9d0457aa9..28436aaae58 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar @@ -8,7 +8,7 @@ env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1 # It will fail to connect but that's fine — we only care about telemetry logging. stdin payload.json ! exec gh send-telemetry -! stderr 'Telemetry payload:' +stderr 'Telemetry payload: none' -- payload.json -- {"events":[{"type":"test","dimensions":{},"measures":{}}]} diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 34806f87449..9512e4b55bb 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -82,19 +82,31 @@ func Main() exitCode { case cfgErr != nil: // Without a valid on-disk config we can't honour user telemetry preferences, so disable it to be safe. telemetryService = &telemetry.NoOpService{} - case os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg): - telemetryService = &telemetry.NoOpService{} default: telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) + telemetryDisabled := os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg) + switch telemetryState { case telemetry.Disabled: telemetryService = &telemetry.NoOpService{} case telemetry.Logged: + // Always construct the real service in log mode so that the log + // flusher runs and surfaces an explicit "Telemetry payload: none" + // marker when no events will be sent. This gives the user an + // observable signal that telemetry is wired up even when their + // context (e.g. GHES) causes events to be dropped. telemetryService = telemetry.NewService( telemetry.LogFlusher(ioStreams.ErrOut, ioStreams.ColorEnabled()), telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), ) + if telemetryDisabled { + telemetryService.Disable() + } case telemetry.Enabled: + if telemetryDisabled { + telemetryService = &telemetry.NoOpService{} + break + } sampleRate := 1 if v, err := strconv.Atoi(os.Getenv("GH_TELEMETRY_SAMPLE_RATE")); err == nil && v >= 0 && v <= 100 { sampleRate = v diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 67fb8b7626e..edcd555c45f 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -166,17 +166,24 @@ func WithSampleRate(rate int) telemetryServiceOption { } // LogFlusher returns a flush function that writes telemetry payloads to the provided log writer. This is used for the "log" telemetry mode, which is intended for debugging and development. +// When there are no events to report (for example the command opted out of telemetry, the user is on GHES, or no events were recorded), a "Telemetry payload: none" marker is written so that the absence of events is observable. var LogFlusher = func(log io.Writer, colorEnabled bool) func(payload SendTelemetryPayload) { return func(payload SendTelemetryPayload) { + header := "Telemetry payload:" + if colorEnabled { + header = ansi.Color(header, "cyan+b") + } + + if len(payload.Events) == 0 { + fmt.Fprintf(log, "%s none\n", header) + return + } + payloadBytes, err := json.Marshal(payload) if err != nil { return } - header := "Telemetry payload:" - if colorEnabled { - header = ansi.Color(header, "cyan+b") - } fmt.Fprintf(log, "%s\n", header) if colorEnabled { @@ -190,8 +197,12 @@ var LogFlusher = func(log io.Writer, colorEnabled bool) func(payload SendTelemet } // GitHubFlusher returns a flush function that sends telemetry payloads to a child `gh send-telemetry` process. This is used for the "enabled" telemetry mode. +// Empty payloads are dropped without spawning a subprocess. var GitHubFlusher = func(executable string) func(payload SendTelemetryPayload) { return func(payload SendTelemetryPayload) { + if len(payload.Events) == 0 { + return + } SpawnSendTelemetry(executable, payload) } } @@ -278,28 +289,29 @@ func (s *service) Flush() { s.mu.Lock() defer s.mu.Unlock() - if s.disabled { - return - } - if s.previouslyCalled { return } s.previouslyCalled = true - if len(s.events) == 0 { + if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate { return } - if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate { - return + // When the service has been disabled mid-invocation (e.g. an enterprise host + // was contacted), discard any recorded events. We still call the flusher + // with an empty payload so that the log-mode flusher can surface the + // absence of telemetry rather than leaving the user staring at silence. + events := s.events + if s.disabled { + events = nil } payload := SendTelemetryPayload{ - Events: make([]PayloadEvent, len(s.events)), + Events: make([]PayloadEvent, len(events)), } - for i, recorded := range s.events { + for i, recorded := range events { dimensions := map[string]string{ "timestamp": recorded.recordedAt.UTC().Format("2006-01-02T15:04:05.000Z"), } diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 207d611eeca..17880cc874e 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -351,6 +351,22 @@ func TestNewServiceLogModeWithColorLogsToWriter(t *testing.T) { assert.Contains(t, output, "\033[", "expected ANSI escape sequences when color is enabled") } +func TestLogFlusherWritesNoneMarkerForEmptyPayload(t *testing.T) { + t.Run("no color", func(t *testing.T) { + var buf bytes.Buffer + LogFlusher(&buf, false)(SendTelemetryPayload{}) + assert.Equal(t, "Telemetry payload: none\n", buf.String()) + }) + + t.Run("with color", func(t *testing.T) { + var buf bytes.Buffer + LogFlusher(&buf, true)(SendTelemetryPayload{}) + output := buf.String() + assert.Contains(t, output, "Telemetry payload:") + assert.Contains(t, output, "none") + }) +} + func TestServiceDeviceIDFallback(t *testing.T) { t.Cleanup(stubDeviceIDError(errors.New("no device id"))) @@ -365,14 +381,19 @@ func TestServiceDeviceIDFallback(t *testing.T) { } func TestServiceFlush(t *testing.T) { - t.Run("does nothing when no events recorded", func(t *testing.T) { + t.Run("calls flusher with empty payload when no events recorded", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) + var captured SendTelemetryPayload called := false - svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc := newService(func(p SendTelemetryPayload) { + called = true + captured = p + }, nil) svc.Flush() - assert.False(t, called, "flusher should not be called with no events") + assert.True(t, called, "flusher should be called even with no events so log mode can surface the absence") + assert.Empty(t, captured.Events, "payload should have no events") }) t.Run("flushes events with merged dimensions", func(t *testing.T) { @@ -599,24 +620,33 @@ func TestWithAdditionalCommonDimensions(t *testing.T) { } func TestServiceDisable(t *testing.T) { - t.Run("prevents flush from sending events", func(t *testing.T) { + t.Run("drops recorded events from flushed payload", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) + var captured SendTelemetryPayload called := false - svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc := newService(func(p SendTelemetryPayload) { + called = true + captured = p + }, nil) svc.Record(ghtelemetry.Event{Type: "test"}) svc.Disable() svc.Flush() - assert.False(t, called, "flusher should not be called after Disable()") + assert.True(t, called, "flusher should still be called so log mode can surface the absence of events") + assert.Empty(t, captured.Events, "recorded events should be dropped after Disable()") }) - t.Run("prevents flush even with multiple recorded events", func(t *testing.T) { + t.Run("drops events even with multiple recorded events", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) + var captured SendTelemetryPayload called := false - svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc := newService(func(p SendTelemetryPayload) { + called = true + captured = p + }, nil) svc.Record(ghtelemetry.Event{Type: "event1"}) svc.Record(ghtelemetry.Event{Type: "event2"}) @@ -624,20 +654,26 @@ func TestServiceDisable(t *testing.T) { svc.Disable() svc.Flush() - assert.False(t, called, "flusher should not be called after Disable()") + assert.True(t, called, "flusher should still be called") + assert.Empty(t, captured.Events, "recorded events should be dropped after Disable()") }) t.Run("can be called before any events are recorded", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) + var captured SendTelemetryPayload called := false - svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc := newService(func(p SendTelemetryPayload) { + called = true + captured = p + }, nil) svc.Disable() svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() - assert.False(t, called, "flusher should not be called when disabled before recording") + assert.True(t, called, "flusher should still be called") + assert.Empty(t, captured.Events, "events recorded after Disable() should be dropped") }) } From 50f0f8fc687e6382df3af6be09f75d966cfa7169 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 20 Apr 2026 21:08:18 +0200 Subject: [PATCH 26/29] feat(skills): detect re-published skills and offer upstream install When installing a skill whose SKILL.md contains github-repo metadata pointing to a different repository, the CLI detects it as a re-published skill and offers to redirect the install to the upstream source. In interactive mode, the user is prompted to choose between the re-publisher (default) and the upstream. In non-interactive mode, the install proceeds from the re-publisher with a notice. The --upstream flag skips the prompt and redirects to upstream directly, enabling non-interactive upstream installs in CI/scripts. If the user chooses upstream, the install restarts from that repo, resolving the latest version and discovering skills fresh. A skill_upstream_redirect telemetry event is emitted to track redirects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/install/install.go | 133 ++++++++++++++- pkg/cmd/skills/install/install_test.go | 220 +++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index d792501fd27..a22249225da 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -1,6 +1,7 @@ package install import ( + "encoding/base64" "errors" "fmt" "io" @@ -57,6 +58,7 @@ type InstallOptions struct { Force bool FromLocal bool // treat SkillSource as a local directory path AllowHiddenDirs bool // include skills in dot-prefixed directories + Upstream bool // install from upstream when re-published skill detected repo ghrepo.Interface // set when SkillSource is a GitHub repository localPath string // set when FromLocal is true @@ -193,6 +195,10 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru return err } + if err := cmdutil.MutuallyExclusive("--from-local and --upstream cannot be used together", opts.FromLocal, opts.Upstream); err != nil { + return err + } + if opts.Pin != "" && opts.SkillName != "" && strings.Contains(opts.SkillName, "@") { return cmdutil.FlagErrorf("cannot use --pin with an inline @version in the skill name") } @@ -212,6 +218,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting") cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository") cmd.Flags().BoolVar(&opts.AllowHiddenDirs, "allow-hidden-dirs", false, "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)") + cmd.Flags().BoolVar(&opts.Upstream, "upstream", false, "Install from the upstream source when a re-published skill is detected") cmdutil.DisableAuthCheckFlag(cmd.Flags().Lookup("from-local")) return cmd @@ -248,13 +255,17 @@ func installRun(opts *InstallOptions) error { // Kick off the visibility fetch in parallel with the install work so // the extra API roundtrip doesn't add latency on the critical path. // The result is consumed when the telemetry event is emitted below. + // Capture repo fields now to avoid a data race if opts.repo is + // swapped during an upstream redirect. type visResult struct { vis discovery.RepoVisibility err error } visCh := make(chan visResult, 1) + visOwner := opts.repo.RepoOwner() + visRepo := opts.repo.RepoName() go func() { - vis, err := discovery.FetchRepoVisibility(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName()) + vis, err := discovery.FetchRepoVisibility(apiClient, hostname, visOwner, visRepo) visCh <- visResult{vis: vis, err: err} }() @@ -293,6 +304,43 @@ func installRun(opts *InstallOptions) error { } } + // Track upstream provenance detection result for telemetry. + upstreamSource := "none" + + // Check if the selected skill was re-published from an upstream source. + // The re-publisher's SKILL.md will have github-repo metadata pointing + // to the original source repo. If detected, offer to install directly + // from upstream instead. + if len(selectedSkills) == 1 && selectedSkills[0].BlobSHA != "" { + upstreamRepo, detected, err := checkUpstreamProvenance(opts, apiClient, hostname, selectedSkills[0], resolved.SHA) + if err != nil { + return err + } + if upstreamRepo != nil { + redirectDims := map[string]string{} + select { + case r := <-visCh: + if r.err == nil && r.vis == discovery.RepoVisibilityPublic { + redirectDims["from_owner"] = visOwner + redirectDims["from_repo"] = visRepo + } + case <-time.After(visibilityWaitTimeout): + } + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_upstream_redirect", + Dimensions: redirectDims, + }) + opts.repo = upstreamRepo + opts.SkillSource = ghrepo.FullName(upstreamRepo) + opts.version = "" + opts.Pin = "" + return installRun(opts) + } + if detected { + upstreamSource = "republisher" + } + } + printPreInstallDisclaimer(opts.IO.ErrOut, cs) selectedHosts, err := resolveHosts(opts, canPrompt) @@ -355,6 +403,7 @@ func installRun(opts *InstallOptions) error { dims := map[string]string{ "agent_hosts": mapAgentHostsToIDs(selectedHosts), "skill_host_type": ghinstance.CategorizeHost(opts.repo.RepoHost()), + "upstream_source": upstreamSource, } select { case r := <-visCh: @@ -1169,3 +1218,85 @@ func filterHiddenDirSkills(opts *InstallOptions, allSkills []discovery.Skill) ([ return standard, nil } + +// checkUpstreamProvenance fetches the skill's SKILL.md via the contents API +// to check if it contains github-repo metadata pointing to a different +// repository, indicating the skill was re-published from an upstream source. +// In interactive mode, the user is asked whether to install from the +// re-publisher or redirect to the upstream. Non-interactive mode always +// installs from the re-publisher. +// Returns (repo to redirect to, whether upstream was detected, error). +func checkUpstreamProvenance(opts *InstallOptions, client *api.Client, hostname string, skill discovery.Skill, commitSHA string) (ghrepo.Interface, bool, error) { + apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", + opts.repo.RepoOwner(), opts.repo.RepoName(), + skill.Path+"/SKILL.md", commitSHA) + var fileResp struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + } + if err := client.REST(hostname, "GET", apiPath, nil, &fileResp); err != nil { + return nil, false, nil //nolint:nilerr // best-effort check; failing to fetch is not fatal + } + if fileResp.Encoding != "base64" { + return nil, false, nil + } + decoded, decodeErr := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(fileResp.Content))) + if decodeErr != nil { + return nil, false, nil //nolint:nilerr // best-effort; decode failure is not fatal + } + content := string(decoded) + + result, parseErr := frontmatter.Parse(content) + if parseErr != nil || result.Metadata.Meta == nil { + //nolint:nilerr // unparseable frontmatter means no upstream to detect + return nil, false, nil + } + + existingRepo, _ := result.Metadata.Meta["github-repo"].(string) + if existingRepo == "" { + return nil, false, nil + } + + currentRepoURL := source.BuildRepoURL(hostname, opts.repo.RepoOwner(), opts.repo.RepoName()) + if existingRepo == currentRepoURL { + return nil, false, nil + } + + upstreamRepo, parseErr := source.ParseRepoURL(existingRepo) + if parseErr != nil { + //nolint:nilerr // invalid repo URL means we can't redirect; install normally + return nil, false, nil + } + + cs := opts.IO.ColorScheme() + upstreamLabel := ghrepo.FullName(upstreamRepo) + repoSource := ghrepo.FullName(opts.repo) + + fmt.Fprintf(opts.IO.ErrOut, "%s This skill was originally published in %s\n", cs.WarningIcon(), upstreamLabel) + + if opts.Upstream { + fmt.Fprintf(opts.IO.ErrOut, "Redirecting install to %s...\n", upstreamLabel) + return upstreamRepo, true, nil + } + + if !opts.IO.CanPrompt() { + fmt.Fprintf(opts.IO.ErrOut, " Installing from %s (use --upstream or interactive mode to choose upstream)\n", repoSource) + return nil, true, nil + } + + choices := []string{ + fmt.Sprintf("%s (re-publisher, recommended)", repoSource), + fmt.Sprintf("%s (upstream)", upstreamLabel), + } + idx, err := opts.Prompter.Select("Install from:", "", choices) + if err != nil { + return nil, true, err + } + + if idx == 1 { + fmt.Fprintf(opts.IO.ErrOut, "Redirecting install to %s...\n", upstreamLabel) + return upstreamRepo, true, nil + } + + return nil, true, nil +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index e4bd074bd5f..b7aa956c5a9 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -125,6 +125,16 @@ func TestNewCmdInstall(t *testing.T) { cli: "monalisa/skills-repo --allow-hidden-dirs", wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project", AllowHiddenDirs: true}, }, + { + name: "upstream flag", + cli: "monalisa/skills-repo git-commit --upstream", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project", Upstream: true}, + }, + { + name: "from-local with --upstream is mutually exclusive", + cli: "--from-local ./local-dir --upstream", + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -2487,3 +2497,213 @@ func TestInstallRun_TelemetryMultipleSkills(t *testing.T) { assert.Contains(t, names, "code-review") assert.Contains(t, names, "git-commit") } + +var republishedContent = heredoc.Doc(` + --- + name: git-commit + description: Writes commits + metadata: + github-repo: https://github.com/monalisa/original-skills + github-tree-sha: upstreamTreeSHA + github-path: skills/git-commit + --- + # Git Commit +`) + +func stubContentsAPI(reg *httpmock.Registry, owner, repo, path, content string) { + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, path)), + httpmock.StringResponse(fmt.Sprintf(`{"content": %q, "encoding": "base64"}`, encoded)), + ) +} + +func TestInstallRun_UpstreamDetection(t *testing.T) { + tests := []struct { + name string + isTTY bool + stubs func(*httpmock.Registry) + opts func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "detects re-published skill and user picks re-publisher", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubContentsAPI(reg, "monalisa", "skills-repo", + "skills/git-commit/SKILL.md", republishedContent) + stubInstallFiles(reg, "monalisa", "skills-repo", + "treeSHA", "blobSHA", republishedContent) + }, + opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{ + SelectFunc: func(_ string, _ string, choices []string) (int, error) { + require.Len(t, choices, 2) + assert.Contains(t, choices[0], "monalisa/skills-repo") + assert.Contains(t, choices[1], "monalisa/original-skills") + return 0, nil + }, + }, + Telemetry: &telemetry.NoOpService{}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStderr: "originally published in monalisa/original-skills", + wantStdout: "Installed git-commit", + }, + { + name: "detects re-published skill and user picks upstream", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubContentsAPI(reg, "monalisa", "skills-repo", + "skills/git-commit/SKILL.md", republishedContent) + stubResolveVersion(reg, "monalisa", "original-skills", "v2.0.0", "upstream456") + stubDiscoverTree(reg, "monalisa", "original-skills", "upstream456", + singleSkillTreeJSON("git-commit", "upTreeSHA", "upBlobSHA")) + stubContentsAPI(reg, "monalisa", "original-skills", + "skills/git-commit/SKILL.md", gitCommitContent) + stubInstallFiles(reg, "monalisa", "original-skills", + "upTreeSHA", "upBlobSHA", gitCommitContent) + }, + opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{ + SelectFunc: func(_ string, _ string, choices []string) (int, error) { + require.Len(t, choices, 2) + assert.Contains(t, choices[0], "monalisa/skills-repo") + assert.Contains(t, choices[1], "monalisa/original-skills") + return 1, nil + }, + }, + Telemetry: &telemetry.NoOpService{}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStderr: "Redirecting install to monalisa/original-skills", + wantStdout: "Installed git-commit", + }, + { + name: "non-interactive defaults to re-publisher with notice", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubContentsAPI(reg, "monalisa", "skills-repo", + "skills/git-commit/SKILL.md", republishedContent) + stubInstallFiles(reg, "monalisa", "skills-repo", + "treeSHA", "blobSHA", republishedContent) + }, + opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Telemetry: &telemetry.NoOpService{}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStderr: "use --upstream", + wantStdout: "Installed git-commit", + }, + { + name: "non-interactive with --upstream redirects to upstream", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubContentsAPI(reg, "monalisa", "skills-repo", + "skills/git-commit/SKILL.md", republishedContent) + stubResolveVersion(reg, "monalisa", "original-skills", "v2.0.0", "upstream456") + stubDiscoverTree(reg, "monalisa", "original-skills", "upstream456", + singleSkillTreeJSON("git-commit", "upTreeSHA", "upBlobSHA")) + stubContentsAPI(reg, "monalisa", "original-skills", + "skills/git-commit/SKILL.md", gitCommitContent) + stubInstallFiles(reg, "monalisa", "original-skills", + "upTreeSHA", "upBlobSHA", gitCommitContent) + }, + opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Telemetry: &telemetry.NoOpService{}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + Upstream: true, + } + }, + wantStderr: "Redirecting install to monalisa/original-skills", + wantStdout: "Installed git-commit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + opts := tt.opts(t, ios, reg) + + err := installRun(opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + }) + } +} From ec4a3ed6bd5739dea9e9ccbc24a4ad9f9d45ddf4 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 21 Apr 2026 17:49:28 +0100 Subject: [PATCH 27/29] fix(telemetry): lower bias in sample bucket calc Signed-off-by: Babak K. Shandiz --- internal/telemetry/telemetry.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index edcd555c45f..e5dcbad9a49 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -3,6 +3,7 @@ package telemetry import ( "bytes" + "encoding/binary" "encoding/json" "errors" "fmt" @@ -232,7 +233,7 @@ func NewService(flusher func(SendTelemetryPayload), opts ...telemetryServiceOpti maps.Copy(commonDimensions, telemetryServiceOpts.additionalDimensions) hash := uuid.NewSHA1(uuid.Nil, []byte(invocationID)) - sampleBucket := hash[0] % 100 + sampleBucket := byte(binary.BigEndian.Uint32(hash[:4]) % 100) s := &service{ flush: flusher, From 0467ed499a63a1bb5af1c1361e4f5d54925a8f2a Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 21 Apr 2026 18:05:28 +0100 Subject: [PATCH 28/29] test(telemetry): assert ANSI escape chars for color codes Signed-off-by: Babak K. Shandiz --- internal/telemetry/telemetry_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 17880cc874e..a796afd677d 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -364,6 +364,7 @@ func TestLogFlusherWritesNoneMarkerForEmptyPayload(t *testing.T) { output := buf.String() assert.Contains(t, output, "Telemetry payload:") assert.Contains(t, output, "none") + assert.Contains(t, output, "\x1b") // ANSI escape char for color codes }) } From 90ef03ea3894fa7902cc952174caaa37772e457d Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 21 Apr 2026 17:24:17 +0200 Subject: [PATCH 29/29] Enable telemetry without env var --- acceptance/testdata/skills/skills-install.txtar | 1 - acceptance/testdata/skills/skills-preview.txtar | 1 - acceptance/testdata/telemetry/command-invocation.txtar | 1 - acceptance/testdata/telemetry/no-telemetry-for-alias.txtar | 1 - acceptance/testdata/telemetry/no-telemetry-for-completion.txtar | 1 - acceptance/testdata/telemetry/no-telemetry-for-extension.txtar | 1 - acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar | 1 - .../testdata/telemetry/no-telemetry-for-send-telemetry.txtar | 1 - .../telemetry/telemetry-failure-does-not-break-command.txtar | 1 - .../telemetry/telemetry-for-official-extension-stub.txtar | 1 - internal/ghcmd/cmd.go | 2 +- 11 files changed, 1 insertion(+), 11 deletions(-) diff --git a/acceptance/testdata/skills/skills-install.txtar b/acceptance/testdata/skills/skills-install.txtar index 0311a0db280..442edb797f6 100644 --- a/acceptance/testdata/skills/skills-install.txtar +++ b/acceptance/testdata/skills/skills-install.txtar @@ -21,7 +21,6 @@ grep 'github-repo' $WORK/custom-skills/git-commit/SKILL.md # Telemetry: skill_install event records agent hosts, repo identifiers, # and (for a public repo) the installed skill name. -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot diff --git a/acceptance/testdata/skills/skills-preview.txtar b/acceptance/testdata/skills/skills-preview.txtar index af1d0bbbe2c..76aa9a6ecb1 100644 --- a/acceptance/testdata/skills/skills-preview.txtar +++ b/acceptance/testdata/skills/skills-preview.txtar @@ -10,7 +10,6 @@ stderr 'not found' # Telemetry: skill_preview event records repo identifiers and, for a # public repo, the skill name. -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 exec gh skill preview github/awesome-copilot git-commit diff --git a/acceptance/testdata/telemetry/command-invocation.txtar b/acceptance/testdata/telemetry/command-invocation.txtar index 86d668da5bf..d174c5c08f1 100644 --- a/acceptance/testdata/telemetry/command-invocation.txtar +++ b/acceptance/testdata/telemetry/command-invocation.txtar @@ -1,5 +1,4 @@ # Telemetry log mode outputs command invocation event to stderr -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 diff --git a/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar b/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar index 733bea11f5c..2bfe0657dc2 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar @@ -2,7 +2,6 @@ # resolved inner command should still record normally — its path is a core # gh command and conveys no user-authored identifier. -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 diff --git a/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar index 20139ce5f41..1204a7913bb 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar @@ -1,5 +1,4 @@ # The completion command should not generate a telemetry event -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 diff --git a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar index 19f3d69ccaf..5e9d2ea5d2a 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar @@ -3,7 +3,6 @@ # organization or project name). [!exec:bash] skip -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 diff --git a/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar index f04fabf364e..e8e1d8ffe97 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar @@ -1,5 +1,4 @@ # GHES users should not get telemetry even when telemetry is enabled -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 env GH_ENTERPRISE_TOKEN=fake-enterprise-token diff --git a/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar index 28436aaae58..15e59fcf5e1 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar @@ -1,5 +1,4 @@ # The send-telemetry command should not itself generate a telemetry event -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1 diff --git a/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar b/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar index ca1fc4b4ad2..14c4b67a6a8 100644 --- a/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar +++ b/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar @@ -1,5 +1,4 @@ # Command completes successfully even when telemetry endpoint is unreachable -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=enabled env GH_TELEMETRY_SAMPLE_RATE=100 env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1 diff --git a/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar index c64739af45d..b200590bf27 100644 --- a/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar +++ b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar @@ -3,7 +3,6 @@ # names come from a fixed, hard-coded registry and do not contain any # user-authored identifiers. -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 9512e4b55bb..67b1564e206 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -84,7 +84,7 @@ func Main() exitCode { telemetryService = &telemetry.NoOpService{} default: telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) - telemetryDisabled := os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg) + telemetryDisabled := mightBeGHESUser(cfg) switch telemetryState { case telemetry.Disabled: