Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ module github.com/cli/cli/v2

go 1.26.0

toolchain go1.26.7
toolchain go1.26.8

require (
charm.land/bubbles/v2 v2.2.1
Expand All @@ -18,7 +18,7 @@ require (
github.com/cenkalti/backoff/v5 v5.0.3
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/cli/go-gh/v2 v2.15.0
github.com/cli/go-gh/v2 v2.16.0
github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24
github.com/cli/oauth v1.2.2
github.com/cli/safeexec v1.0.1
Expand Down Expand Up @@ -57,7 +57,7 @@ require (
github.com/vmihailenco/msgpack/v5 v5.4.1
github.com/yuin/goldmark v1.8.5
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.55.0
golang.org/x/crypto v0.56.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.45.0
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8
github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/cli/go-gh/v2 v2.15.0 h1:LF5lDLs6yLaUgUlvki/D9syUGYnYaJXjzYXpazTqYdw=
github.com/cli/go-gh/v2 v2.15.0/go.mod h1:OaJTFtHJapQq670h/3L0vqm4NwZGoJmSAVctWiY+3pQ=
github.com/cli/go-gh/v2 v2.16.0 h1:xaePUubgeuj4wKz87NIo+zFQtuB6566K8cAGTh0Ctjc=
github.com/cli/go-gh/v2 v2.16.0/go.mod h1:OaJTFtHJapQq670h/3L0vqm4NwZGoJmSAVctWiY+3pQ=
github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 h1:QDrhR4JA2n3ij9YQN0u5ZeuvRIIvsUGmf5yPlTS0w8E=
github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24/go.mod h1:rr9GNING0onuVw8MnracQHn7PcchnFlP882Y0II2KZk=
github.com/cli/oauth v1.2.2 h1:/qG/wok8jzu66tx7q+duGOIp4DT5P/ACXrdc33UoNUQ=
Expand Down Expand Up @@ -561,8 +561,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
Expand Down
19 changes: 19 additions & 0 deletions internal/attachments/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"strings"

"github.com/cli/cli/v2/internal/gh/ghtelemetry"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
Expand Down Expand Up @@ -39,6 +40,24 @@ func (f *Flag) Changed() bool {
return f.flag.Changed
}

// RecordTelemetry records how many attachment flags were provided.
func (f *Flag) RecordTelemetry(command string, recorder ghtelemetry.CommandRecorder) {
if recorder == nil || !f.Changed() {
return
}

recorder.SetSampleRate(ghtelemetry.SAMPLE_ALL)
recorder.Record(ghtelemetry.Event{
Type: "attachment_invocation",
Dimensions: ghtelemetry.Dimensions{
"command": command,
},
Measures: ghtelemetry.Measures{
"attach_count": int64(len(f.values)),
},
})
}

// UserAssets validates the files named by the attachment flag, keeping them in
// the order they were written. It returns nothing when the flag was not passed.
func (f *Flag) UserAssets() ([]UserAsset, error) {
Expand Down
54 changes: 54 additions & 0 deletions internal/attachments/flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strings"
"testing"

"github.com/cli/cli/v2/internal/gh/ghtelemetry"
"github.com/cli/cli/v2/internal/telemetry"
"github.com/google/shlex"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
Expand Down Expand Up @@ -88,6 +90,58 @@ func TestAddFlag(t *testing.T) {
}
}

func TestFlagRecordTelemetry(t *testing.T) {
tests := []struct {
name string
input string
wantEvent bool
wantCount int64
}{
{
name: "flag not passed",
input: "",
},
{
name: "one attachment",
input: "--attach ./first.png",
wantEvent: true,
wantCount: 1,
},
{
name: "several attachments before validation",
input: "--attach ./first.png --attach ./second.png --attach ./third.png",
wantEvent: true,
wantCount: 3,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, attachFlag := attachCmd(t, tt.input)
recorder := &telemetry.CommandRecorderSpy{}

attachFlag.RecordTelemetry("gh issue comment", recorder)

if !tt.wantEvent {
assert.Empty(t, recorder.Events)
assert.Zero(t, recorder.LastSampleRate)
return
}

require.Equal(t, ghtelemetry.SAMPLE_ALL, recorder.LastSampleRate)
require.Equal(t, []ghtelemetry.Event{{
Type: "attachment_invocation",
Dimensions: ghtelemetry.Dimensions{
"command": "gh issue comment",
},
Measures: ghtelemetry.Measures{
"attach_count": tt.wantCount,
},
}}, recorder.Events)
})
}
}

func TestFlagUserAssets(t *testing.T) {
tests := []struct {
name string
Expand Down
28 changes: 14 additions & 14 deletions internal/config/auth_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -987,38 +987,38 @@ func TestHostForAPIHost(t *testing.T) {
}{
{
name: "no hosts configure an api_host",
lookup: "api.example.com",
lookup: "api-gateway.example.com",
wantFound: false,
},
{
name: "a host configures the api_host",
apiHosts: map[string]string{"github.com": "api.example.com"},
lookup: "api.example.com",
apiHosts: map[string]string{"github.com": "api-gateway.example.com"},
lookup: "api-gateway.example.com",
wantHost: "github.com",
wantFound: true,
},
{
name: "matching is case insensitive",
apiHosts: map[string]string{"github.com": "API.example.com"},
lookup: "api.example.com",
apiHosts: map[string]string{"github.com": "API-gateway.example.com"},
lookup: "api-gateway.example.com",
wantHost: "github.com",
wantFound: true,
},
{
name: "an unrelated api_host does not match",
apiHosts: map[string]string{"github.com": "api.example.com"},
apiHosts: map[string]string{"github.com": "api-gateway.example.com"},
lookup: "api.other.com",
wantFound: false,
},
{
name: "an empty lookup matches nothing",
apiHosts: map[string]string{"github.com": "api.example.com"},
apiHosts: map[string]string{"github.com": "api-gateway.example.com"},
lookup: "",
wantFound: false,
},
{
name: "the right host is chosen when several configure an api_host",
apiHosts: map[string]string{"github.com": "api.example.com", "ghe.io": "api.ghe.io"},
apiHosts: map[string]string{"github.com": "api-gateway.example.com", "ghe.io": "api.ghe.io"},
lookup: "api.ghe.io",
wantHost: "ghe.io",
wantFound: true,
Expand All @@ -1028,8 +1028,8 @@ func TestHostForAPIHost(t *testing.T) {
// only hopes to catch an ordering regression rather than
// guaranteeing it on every run.
name: "the first lexical match is returned when several matches found",
apiHosts: map[string]string{"A.github.com": "api.example.com", "a.github.com": "api.example.com"},
lookup: "api.example.com",
apiHosts: map[string]string{"A.github.com": "api-gateway.example.com", "a.github.com": "api-gateway.example.com"},
lookup: "api-gateway.example.com",
wantHost: "A.github.com",
wantFound: true,
},
Expand Down Expand Up @@ -1084,20 +1084,20 @@ func TestAPIHostForHost(t *testing.T) {
},
{
name: "the host configures an api_host",
apiHost: "api.example.com",
apiHost: "api-gateway.example.com",
lookup: "github.com",
wantAPIHost: "api.example.com",
wantAPIHost: "api-gateway.example.com",
wantFound: true,
},
{
name: "an empty host matches nothing",
apiHost: "api.example.com",
apiHost: "api-gateway.example.com",
lookup: "",
wantFound: false,
},
{
name: "an unknown host matches nothing",
apiHost: "api.example.com",
apiHost: "api-gateway.example.com",
lookup: "ghe.io",
wantFound: false,
},
Expand Down
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -646,10 +646,21 @@ type ConfigOption struct {
Description string
DefaultValue string
AllowedValues []string
PerHostOnly bool
CurrentValue func(c gh.Config, hostname string) string
}

var Options = []ConfigOption{
{
Key: apiHostKey,
Description: "experimental: the hostname to use when making API requests for a GitHub host. Note: this is not a security boundary and requests to the canonical host will remain authenticated",
DefaultValue: "",
PerHostOnly: true,
CurrentValue: func(c gh.Config, hostname string) string {
apiHost, _ := c.Authentication().APIHostForHost(hostname)
return apiHost
},
},
{
Key: gitProtocolKey,
Description: "the protocol to use for git clone and push operations",
Expand Down
64 changes: 33 additions & 31 deletions pkg/cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/factory"
Expand All @@ -34,13 +35,14 @@ const (
)

type ApiOptions struct {
AppVersion string
InvokingAgent string
BaseRepo func() (ghrepo.Interface, error)
Branch func() (string, error)
Config func() (gh.Config, error)
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
AppVersion string
InvokingAgent string
BaseRepo func() (ghrepo.Interface, error)
Branch func() (string, error)
Config func() (gh.Config, error)
HttpClient func(api.HTTPClientOptions) (*http.Client, error)
IO *iostreams.IOStreams
TelemetryDisabler ghtelemetry.Disabler

Hostname string
RequestMethod string
Expand All @@ -65,12 +67,14 @@ type ApiOptions struct {

func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command {
opts := ApiOptions{
AppVersion: f.AppVersion,
InvokingAgent: f.InvokingAgent,
BaseRepo: f.BaseRepo,
Branch: f.Branch,
Config: f.Config,
IO: f.IOStreams,
AppVersion: f.AppVersion,
InvokingAgent: f.InvokingAgent,
BaseRepo: f.BaseRepo,
Branch: f.Branch,
Config: f.Config,
HttpClient: api.NewHTTPClient,
IO: f.IOStreams,
TelemetryDisabler: f.TelemetryDisabler,
}

cmd := &cobra.Command{
Expand Down Expand Up @@ -389,25 +393,23 @@ func apiRun(opts *ApiOptions) error {
}

if opts.HttpClient == nil {
opts.HttpClient = func() (*http.Client, error) {
log := opts.IO.ErrOut
if opts.Verbose {
log = opts.IO.Out
}
opts := api.HTTPClientOptions{
AppVersion: opts.AppVersion,
InvokingAgent: opts.InvokingAgent,
CacheTTL: opts.CacheTTL,
Config: cfg.Authentication(),
EnableCache: opts.CacheTTL > 0,
Log: log,
LogColorize: opts.IO.ColorEnabled(),
LogVerboseHTTP: opts.Verbose,
}
return api.NewHTTPClient(opts)
}
opts.HttpClient = api.NewHTTPClient
}
httpClient, err := opts.HttpClient()
log := opts.IO.ErrOut
if opts.Verbose {
log = opts.IO.Out
}
httpClient, err := opts.HttpClient(api.HTTPClientOptions{
AppVersion: opts.AppVersion,
InvokingAgent: opts.InvokingAgent,
CacheTTL: opts.CacheTTL,
Config: cfg.Authentication(),
EnableCache: opts.CacheTTL > 0,
Log: log,
LogColorize: opts.IO.ColorEnabled(),
LogVerboseHTTP: opts.Verbose,
TelemetryDisabler: opts.TelemetryDisabler,
})
if err != nil {
return err
}
Expand Down
Loading
Loading