From 091c24d7025772ca632bf443e3c3d4d8f9bdce91 Mon Sep 17 00:00:00 2001 From: BagToad <47394200+BagToad@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:50:26 -0600 Subject: [PATCH 01/22] Record attachment operation counts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- internal/attachments/attach.go | 33 ++++++--- internal/attachments/attach_test.go | 49 +++++++----- internal/attachments/doc.go | 13 ++-- internal/attachments/flags.go | 19 ----- internal/attachments/flags_test.go | 103 +++++++++++++++++++++++--- internal/attachments/references.go | 41 ++++++---- internal/attachments/telemetry.go | 68 +++++++++++++++++ internal/attachments/test.go | 33 +++++++++ internal/gh/ghtelemetry/telemetry.go | 2 + internal/telemetry/fake.go | 12 ++- internal/telemetry/telemetry.go | 25 +++++-- internal/telemetry/telemetry_test.go | 21 ++++++ pkg/cmd/issue/comment/comment.go | 4 +- pkg/cmd/issue/comment/comment_test.go | 13 +++- pkg/cmd/issue/create/create.go | 14 ++-- pkg/cmd/issue/create/create_test.go | 52 ++++++++++--- pkg/cmd/issue/edit/edit.go | 18 +++-- pkg/cmd/issue/edit/edit_test.go | 30 +++++++- pkg/cmd/pr/comment/comment.go | 4 +- pkg/cmd/pr/comment/comment_test.go | 13 +++- pkg/cmd/pr/create/create.go | 14 ++-- pkg/cmd/pr/create/create_test.go | 29 +++++++- pkg/cmd/pr/edit/edit.go | 20 ++--- pkg/cmd/pr/edit/edit_test.go | 29 +++++++- pkg/cmd/pr/shared/commentable.go | 6 +- pkg/cmd/pr/shared/commentable_test.go | 24 ++++++ 26 files changed, 554 insertions(+), 135 deletions(-) create mode 100644 internal/attachments/telemetry.go diff --git a/internal/attachments/attach.go b/internal/attachments/attach.go index 73c5b7e195c..de2a59432a7 100644 --- a/internal/attachments/attach.go +++ b/internal/attachments/attach.go @@ -6,21 +6,28 @@ import ( "strings" ) +// UploadResult reports the successful upload and markdown operation counts. +type UploadResult struct { + Uploaded int + AppendOperations int + ReplaceOperations int +} + // UploadAndAttach uploads assets in order and stops at the first failure. It // points existing references at the URLs of successful uploads and appends only // successful uploads the markdown did not reference. Assets after a failure // are not attempted. // -// The returned count reports how many assets reached the server. A caller must -// write the returned markdown when that count is above zero, even when the -// returned error is non-nil. An upload cannot be undone and there is no endpoint -// to delete one, so discarding that markdown would orphan the successful assets. -// A count of zero means nothing was uploaded and nothing is lost by writing -// nothing. +// The result reports how many assets reached the server and how those successful +// uploads changed the markdown. A caller must write the returned markdown when +// Uploaded is above zero, even when the returned error is non-nil. An upload +// cannot be undone and there is no endpoint to delete one, so discarding that +// markdown would orphan the successful assets. An Uploaded count of zero means +// nothing was uploaded and nothing is lost by writing nothing. // // The markdown is returned unchanged when it could not be rewritten, so a // caller that assigns the result in place never destroys what it was given. -func (u *Uploader) UploadAndAttach(ctx context.Context, md string, assets []UserAsset) (string, int, error) { +func (u *Uploader) UploadAndAttach(ctx context.Context, md string, assets []UserAsset) (string, UploadResult, error) { args := make([]attachmentArg, len(assets)) for i, a := range assets { f := a.getAsset() @@ -29,11 +36,11 @@ func (u *Uploader) UploadAndAttach(ctx context.Context, md string, assets []User attachableMD, err := newAttachableMarkdown(md, args) if err != nil { - return md, 0, err + return md, UploadResult{}, err } var failures []error - uploaded := 0 + result := UploadResult{} for i, a := range assets { assetURL, err := u.upload(ctx, a) if err != nil { @@ -42,16 +49,18 @@ func (u *Uploader) UploadAndAttach(ctx context.Context, md string, assets []User break } args[i].URL = assetURL - uploaded++ + result.Uploaded++ } attachedMD, err := attachAssetsToMarkdown(attachableMD) if err != nil { failures = append(failures, err) - return md, uploaded, errors.Join(failures...) + return md, result, errors.Join(failures...) } - return appendUnreferenced(attachedMD, assets), uploaded, errors.Join(failures...) + result.AppendOperations = len(attachedMD.ToAppend) + result.ReplaceOperations = attachedMD.ReplaceOperations + return appendUnreferenced(attachedMD, assets), result, errors.Join(failures...) } // appendUnreferenced adds a paragraph for every attachment the author never diff --git a/internal/attachments/attach_test.go b/internal/attachments/attach_test.go index c1698587161..1288d1cf129 100644 --- a/internal/attachments/attach_test.go +++ b/internal/attachments/attach_test.go @@ -41,14 +41,15 @@ func TestUploaderUploadAndAttach(t *testing.T) { } tests := []struct { - name string - files []string - args []string - body string - uploads []upload - wantBody string - // What a caller keys on to decide whether a body is worth writing. + name string + files []string + args []string + body string + uploads []upload + wantBody string wantUploaded int + wantAppend int + wantReplace int wantErr string }{ { @@ -59,6 +60,7 @@ func TestUploaderUploadAndAttach(t *testing.T) { uploads: []upload{{201, `{"url":"https://github.com/user-attachments/assets/1"}`}}, wantBody: "See below\n\n![login](https://github.com/user-attachments/assets/1)", wantUploaded: 1, + wantAppend: 1, }, { name: "appends a video as a paragraph of its own", @@ -70,6 +72,7 @@ func TestUploaderUploadAndAttach(t *testing.T) { // paragraph, so it must not land on the end of the line above. wantBody: "Watch this:\n\nhttps://github.com/user-attachments/assets/2", wantUploaded: 1, + wantAppend: 1, }, { name: "appends to an empty body without leading blank lines", @@ -79,6 +82,7 @@ func TestUploaderUploadAndAttach(t *testing.T) { uploads: []upload{{201, `{"url":"https://github.com/user-attachments/assets/1"}`}}, wantBody: "![login](https://github.com/user-attachments/assets/1)", wantUploaded: 1, + wantAppend: 1, }, { name: "does not stack blank lines on a body that ends with them", @@ -88,6 +92,7 @@ func TestUploaderUploadAndAttach(t *testing.T) { uploads: []upload{{201, `{"url":"https://github.com/user-attachments/assets/1"}`}}, wantBody: "See below\n\n![login](https://github.com/user-attachments/assets/1)", wantUploaded: 1, + wantAppend: 1, }, { name: "appends several assets in the order they were written", @@ -100,6 +105,7 @@ func TestUploaderUploadAndAttach(t *testing.T) { "![After the fix](https://example.com/2)\n\n" + "https://example.com/3", wantUploaded: 3, + wantAppend: 3, }, { name: "rewrites a reference in place instead of appending it", @@ -109,6 +115,7 @@ func TestUploaderUploadAndAttach(t *testing.T) { uploads: []upload{{201, `{"url":"https://example.com/1"}`}}, wantBody: "The error:\n\n![the login screen](https://example.com/1)\n\nThat is all.", wantUploaded: 1, + wantReplace: 1, }, { name: "rewrites what the body references and appends what it does not", @@ -118,6 +125,8 @@ func TestUploaderUploadAndAttach(t *testing.T) { uploads: []upload{{201, `{"url":"https://example.com/1"}`}, {201, `{"url":"https://example.com/2"}`}}, wantBody: "![the login screen](https://example.com/1)\n\n![after](https://example.com/2)", wantUploaded: 2, + wantAppend: 1, + wantReplace: 1, }, { // Three files and two replies: c is never attempted, which the @@ -129,6 +138,7 @@ func TestUploaderUploadAndAttach(t *testing.T) { uploads: []upload{{201, `{"url":"https://example.com/1"}`}, {404, `{"message":"Not Found"}`}}, wantBody: "Three files\n\n![a](https://example.com/1)", wantUploaded: 1, + wantAppend: 1, wantErr: "could not upload ./b.png: attaching files requires write access to the repository", }, { @@ -170,6 +180,7 @@ func TestUploaderUploadAndAttach(t *testing.T) { uploads: []upload{{201, `{"url":"https://example.com/1"}`}}, wantBody: "[clip][c]\n\n[c]: https://example.com/1", wantUploaded: 1, + wantReplace: 1, }, { // The only place a UserAsset becomes an attachmentArg, so the only row @@ -183,6 +194,7 @@ func TestUploaderUploadAndAttach(t *testing.T) { uploads: []upload{{201, `{"url":"https://example.com/1"}`}}, wantBody: "The crash [repro.mp4](https://example.com/1) reproduces every time.", wantUploaded: 1, + wantReplace: 1, }, } @@ -202,7 +214,7 @@ func TestUploaderUploadAndAttach(t *testing.T) { ) } - body, uploaded, err := testUploader(reg).UploadAndAttach(context.Background(), tt.body, assets) + body, result, err := testUploader(reg).UploadAndAttach(context.Background(), tt.body, assets) if tt.wantErr == "" { require.NoError(t, err) @@ -210,7 +222,10 @@ func TestUploaderUploadAndAttach(t *testing.T) { require.EqualError(t, err, tt.wantErr) } assert.Equal(t, tt.wantBody, body) - assert.Equal(t, tt.wantUploaded, uploaded) + assert.Equal(t, tt.wantUploaded, result.Uploaded) + assert.Equal(t, tt.wantAppend, result.AppendOperations) + assert.Equal(t, tt.wantReplace, result.ReplaceOperations) + assert.Equal(t, result.Uploaded, result.AppendOperations+result.ReplaceOperations) // The bytes go up in the order they were attached, so a failure // stops the ones after it rather than reordering them. @@ -239,11 +254,11 @@ func TestUploaderUploadAndAttachUploadsOnceForRepeatedReferences(t *testing.T) { httpmock.StatusStringResponse(201, `{"url":"https://example.com/1"}`), ) - body, uploaded, err := testUploader(reg).UploadAndAttach(context.Background(), + body, result, err := testUploader(reg).UploadAndAttach(context.Background(), "![one](./shot.png)\n\ntext\n\n![two](./shot.png)", assets) require.NoError(t, err) - assert.Equal(t, 1, uploaded) + assert.Equal(t, UploadResult{Uploaded: 1, ReplaceOperations: 1}, result) assert.Equal(t, "![one](https://example.com/1)\n\ntext\n\n![two](https://example.com/1)", body) assert.Len(t, reg.Requests, 1) } @@ -252,10 +267,10 @@ func TestUploaderUploadAndAttachNoAssets(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) - body, uploaded, err := testUploader(reg).UploadAndAttach(context.Background(), "unchanged\n", nil) + body, result, err := testUploader(reg).UploadAndAttach(context.Background(), "unchanged\n", nil) require.NoError(t, err) - assert.Zero(t, uploaded) + assert.Equal(t, UploadResult{}, result) assert.Equal(t, "unchanged\n", body) assert.Empty(t, reg.Requests) } @@ -299,12 +314,12 @@ func TestUploaderUploadAndAttachDoesNotLeakTheAssetURLIntoAnError(t *testing.T) httpmock.StatusStringResponse(404, `{"message":"Not Found"}`), ) - body, uploaded, err := testUploader(reg).UploadAndAttach(context.Background(), "", assets) + body, result, err := testUploader(reg).UploadAndAttach(context.Background(), "", assets) require.Error(t, err) // One asset is up and cannot be deleted, so the caller must write this // body even though the call failed. - assert.Equal(t, 1, uploaded) + assert.Equal(t, UploadResult{Uploaded: 1, AppendOperations: 1}, result) assert.NotContains(t, err.Error(), "secret-asset") assert.Contains(t, body, "secret-asset") } @@ -324,9 +339,9 @@ func TestUploaderUploadAndAttachAbsolutePathReference(t *testing.T) { httpmock.StatusStringResponse(201, `{"url":"https://example.com/1"}`), ) - body, uploaded, err := testUploader(reg).UploadAndAttach(context.Background(), "![shot](./shot.png)", assets) + body, result, err := testUploader(reg).UploadAndAttach(context.Background(), "![shot](./shot.png)", assets) require.NoError(t, err) - assert.Equal(t, 1, uploaded) + assert.Equal(t, UploadResult{Uploaded: 1, ReplaceOperations: 1}, result) assert.Equal(t, "![shot](https://example.com/1)", body) } diff --git a/internal/attachments/doc.go b/internal/attachments/doc.go index f13473e77c9..f6f7b3ed5e7 100644 --- a/internal/attachments/doc.go +++ b/internal/attachments/doc.go @@ -25,16 +25,17 @@ // // // Every reasonable cancellation possible belongs here. // -// md, uploaded, err := uploader.UploadAndAttach(ctx, md, attachmentArgs) -// if uploaded == 0 { +// md, result, err := uploader.UploadAndAttach(ctx, md, attachmentArgs) +// if result.Uploaded == 0 { // return err // } // // // Write md to target, then report err alongside whatever the write // // returned. // -// UploadAndAttach reports how many assets were successfully uploaded. The -// caller must write the markdown when that count is above zero, including after -// a partial failure, because what did upload must be referenced by something. -// At zero nothing is stranded and nothing is written. +// UploadAndAttach reports how many assets were successfully uploaded and how +// they changed the markdown. The caller must write the markdown when Uploaded +// is above zero, including after a partial failure, because what did upload must +// be referenced by something. At zero nothing is stranded and nothing is +// written. package attachments diff --git a/internal/attachments/flags.go b/internal/attachments/flags.go index 10e42fd6fca..c47117c79fc 100644 --- a/internal/attachments/flags.go +++ b/internal/attachments/flags.go @@ -6,7 +6,6 @@ import ( "os" "strings" - "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -40,24 +39,6 @@ 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) { diff --git a/internal/attachments/flags_test.go b/internal/attachments/flags_test.go index af22157715e..8a8b8b3fb07 100644 --- a/internal/attachments/flags_test.go +++ b/internal/attachments/flags_test.go @@ -1,6 +1,7 @@ package attachments import ( + "errors" "fmt" "io/fs" "os" @@ -90,12 +91,14 @@ func TestAddFlag(t *testing.T) { } } -func TestFlagRecordTelemetry(t *testing.T) { +func TestInvocationTelemetry(t *testing.T) { tests := []struct { - name string - input string - wantEvent bool - wantCount int64 + name string + input string + wantEvent bool + wantCount int64 + operations *UploadResult + wantValidationErr string }{ { name: "flag not passed", @@ -113,14 +116,50 @@ func TestFlagRecordTelemetry(t *testing.T) { wantEvent: true, wantCount: 3, }, + { + name: "successful markdown operations", + input: "--attach ./first.png --attach ./second.png", + wantEvent: true, + wantCount: 2, + operations: &UploadResult{ + Uploaded: 2, + AppendOperations: 1, + ReplaceOperations: 1, + }, + }, + { + name: "upload flow with no completed operations", + input: "--attach ./first.png", + wantEvent: true, + wantCount: 1, + operations: &UploadResult{}, + }, + { + name: "over attachment limit", + input: strings.Repeat("--attach ./missing.png ", maxAttachments+1), + wantEvent: true, + wantCount: maxAttachments + 1, + wantValidationErr: "`--attach` accepts at most 50 values per command", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, attachFlag := attachCmd(t, tt.input) recorder := &telemetry.CommandRecorderSpy{} + invocationTelemetry := NewInvocationTelemetry(attachFlag, recorder) - attachFlag.RecordTelemetry("gh issue comment", recorder) + invocationTelemetry.start("gh issue comment") + assert.Empty(t, recorder.Events) + if tt.operations != nil { + invocationTelemetry.RecordOperations(*tt.operations) + } + if tt.wantValidationErr != "" { + _, err := attachFlag.UserAssets() + require.EqualError(t, err, tt.wantValidationErr) + } + recorder.Flush() + recorder.Flush() if !tt.wantEvent { assert.Empty(t, recorder.Events) @@ -129,19 +168,65 @@ func TestFlagRecordTelemetry(t *testing.T) { } require.Equal(t, ghtelemetry.SAMPLE_ALL, recorder.LastSampleRate) - require.Equal(t, []ghtelemetry.Event{{ + wantEvents := []ghtelemetry.Event{{ Type: "attachment_invocation", Dimensions: ghtelemetry.Dimensions{ "command": "gh issue comment", }, Measures: ghtelemetry.Measures{ - "attach_count": tt.wantCount, + "attach_count": tt.wantCount, + "append_ops_count": 0, + "replace_ops_count": 0, }, - }}, recorder.Events) + }} + if tt.operations != nil { + wantEvents[0].Measures["append_ops_count"] = int64(tt.operations.AppendOperations) + wantEvents[0].Measures["replace_ops_count"] = int64(tt.operations.ReplaceOperations) + } + require.Equal(t, wantEvents, recorder.Events) }) } } +func TestInvocationTelemetryWrapArgsRecordsBeforePersistentPreRunError(t *testing.T) { + recorder := &telemetry.CommandRecorderSpy{} + cmd := &cobra.Command{ + Use: "comment", + Args: cobra.ExactArgs(1), + RunE: func(*cobra.Command, []string) error { + return errors.New("run should not be called") + }, + } + attachFlag := AddFlag(cmd) + invocationTelemetry := NewInvocationTelemetry(attachFlag, recorder) + cmd.Args = invocationTelemetry.WrapArgs(cmd.Args) + + root := &cobra.Command{ + Use: "gh", + SilenceErrors: true, + SilenceUsage: true, + PersistentPreRunE: func(*cobra.Command, []string) error { + return errors.New("authentication failed") + }, + } + root.AddCommand(cmd) + root.SetArgs([]string{"comment", "1", "--attach", "./shot.png"}) + + _, err := root.ExecuteC() + require.EqualError(t, err, "authentication failed") + recorder.Flush() + + require.Equal(t, []ghtelemetry.Event{{ + Type: "attachment_invocation", + Dimensions: ghtelemetry.Dimensions{"command": "gh comment"}, + Measures: ghtelemetry.Measures{ + "attach_count": 1, + "append_ops_count": 0, + "replace_ops_count": 0, + }, + }}, recorder.Events) +} + func TestFlagUserAssets(t *testing.T) { tests := []struct { name string diff --git a/internal/attachments/references.go b/internal/attachments/references.go index 85db0bd345f..7cc42f3cb15 100644 --- a/internal/attachments/references.go +++ b/internal/attachments/references.go @@ -14,9 +14,9 @@ import ( ) // This file finds the places a body already points at an attached file, and -// repoints them. A file the author never mentioned is not its business: it is -// reported in ToAppend and appended by attach.go, which knows that an image and -// a video append different markdown. +// repoints them. A file with no rewritten reference is reported in ToAppend and +// appended by attach.go, which knows that an image and a video append different +// markdown. // // The parts below run in that order: the two entry points, finding the // references, working out which attached file each one names, locating its @@ -43,13 +43,15 @@ type attachmentArg struct { RendersAsPlayer bool } -// attachedMarkdown is the outcome of attaching, one field per flow. A file the -// author already referenced is rewritten where they wrote it. A file they never -// mentioned is left for the caller to append, because appending needs to know -// how an image and a video each render and this file does not. +// attachedMarkdown is the outcome of attaching, one field per flow. A file +// rewritten where the author referenced it contributes one replacement, +// regardless of how many references changed. A file that was not rewritten is +// left for the caller to append, because appending needs to know how an image +// and a video each render and this file does not. type attachedMarkdown struct { - Rewritten string - ToAppend []attachmentArg + Rewritten string + ToAppend []attachmentArg + ReplaceOperations int } // attachableMarkdown is markdown scanned for the files it references, held @@ -78,9 +80,10 @@ func newAttachableMarkdown(md string, attachmentArgs []attachmentArg) (attachabl return attachableMarkdown{markdown: md, refs: refs, args: attachmentArgs}, nil } -// attachAssetsToMarkdown points every markdown reference to an attached file -// at that file's asset URL, and reports which arguments the markdown never -// referenced. +// attachAssetsToMarkdown points every rewritable markdown reference to an +// attached file at that file's asset URL. It reports one replacement per +// successfully rewritten attachment and which uploaded arguments remain to +// append. // // ![alt](./file) alone in a paragraph image: swap the path video: the bare URL, which plays // ![alt](./file) anywhere else image: swap the path video: becomes [alt](URL) @@ -173,12 +176,22 @@ func attachAssetsToMarkdown(v attachableMarkdown) (attachedMarkdown, error) { out, written := applyEdits(md, edits) var unreferenced []attachmentArg + replaced := 0 for i, a := range attachmentArgs { - if a.URL != "" && !written[i] { + if a.URL == "" { + continue + } + if written[i] { + replaced++ + } else { unreferenced = append(unreferenced, a) } } - return attachedMarkdown{Rewritten: out, ToAppend: unreferenced}, nil + return attachedMarkdown{ + Rewritten: out, + ToAppend: unreferenced, + ReplaceOperations: replaced, + }, nil } // checkVideoReferenceImages returns an error naming every video the markdown diff --git a/internal/attachments/telemetry.go b/internal/attachments/telemetry.go new file mode 100644 index 00000000000..6a45086c667 --- /dev/null +++ b/internal/attachments/telemetry.go @@ -0,0 +1,68 @@ +package attachments + +import ( + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/spf13/cobra" +) + +// InvocationTelemetry records telemetry for one command invocation using +// attachments. +type InvocationTelemetry struct { + flag *Flag + recorder ghtelemetry.CommandRecorder + event *ghtelemetry.Event +} + +// NewInvocationTelemetry creates attachment telemetry for flag. +func NewInvocationTelemetry(flag *Flag, recorder ghtelemetry.CommandRecorder) *InvocationTelemetry { + return &InvocationTelemetry{ + flag: flag, + recorder: recorder, + } +} + +// WrapArgs starts attachment telemetry before argument validation. +func (t *InvocationTelemetry) WrapArgs(validate cobra.PositionalArgs) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + t.start(cmd.CommandPath()) + return validate(cmd, args) + } +} + +func (t *InvocationTelemetry) start(command string) { + if t == nil { + return + } + + t.event = nil + if t.recorder == nil || t.flag == nil || !t.flag.Changed() { + return + } + + event := &ghtelemetry.Event{ + Type: "attachment_invocation", + Dimensions: ghtelemetry.Dimensions{ + "command": command, + }, + Measures: ghtelemetry.Measures{ + "attach_count": int64(len(t.flag.values)), + "append_ops_count": 0, + "replace_ops_count": 0, + }, + } + t.event = event + t.recorder.SetSampleRate(ghtelemetry.SAMPLE_ALL) + t.recorder.RecordDeferred(func() ghtelemetry.Event { + return *event + }) +} + +// RecordOperations adds successful markdown operations to the invocation. +func (t *InvocationTelemetry) RecordOperations(result UploadResult) { + if t == nil || t.event == nil { + return + } + + t.event.Measures["append_ops_count"] = int64(result.AppendOperations) + t.event.Measures["replace_ops_count"] = int64(result.ReplaceOperations) +} diff --git a/internal/attachments/test.go b/internal/attachments/test.go index 12106dbcd90..00bd63a8f35 100644 --- a/internal/attachments/test.go +++ b/internal/attachments/test.go @@ -7,6 +7,7 @@ import ( "strconv" "testing" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/pkg/httpmock" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -39,6 +40,38 @@ func NewTestAssets(t *testing.T, names ...string) []UserAsset { return assets } +// NewTestInvocationTelemetry returns telemetry started with the given +// attachment count. +func NewTestInvocationTelemetry(t *testing.T, recorder ghtelemetry.CommandRecorder, attachCount int) *InvocationTelemetry { + t.Helper() + + cmd := &cobra.Command{Use: "test"} + attachFlag := AddFlag(cmd) + for i := range attachCount { + require.NoError(t, cmd.Flags().Set(flagName, "attachment-"+strconv.Itoa(i)+".png")) + } + invocationTelemetry := NewInvocationTelemetry(attachFlag, recorder) + invocationTelemetry.start("gh test") + return invocationTelemetry +} + +// AssertTestTelemetryEvents verifies the completed invocation event shape. +func AssertTestTelemetryEvents(t *testing.T, events []ghtelemetry.Event, attachCount int, result UploadResult) { + t.Helper() + + require.Equal(t, []ghtelemetry.Event{ + { + Type: "attachment_invocation", + Dimensions: ghtelemetry.Dimensions{"command": "gh test"}, + Measures: ghtelemetry.Measures{ + "attach_count": int64(attachCount), + "append_ops_count": int64(result.AppendOperations), + "replace_ops_count": int64(result.ReplaceOperations), + }, + }, + }, events) +} + // StubUpload registers one upload of name against repositoryID, answering with // status and body. // diff --git a/internal/gh/ghtelemetry/telemetry.go b/internal/gh/ghtelemetry/telemetry.go index 197b955b4c1..8f85fb64657 100644 --- a/internal/gh/ghtelemetry/telemetry.go +++ b/internal/gh/ghtelemetry/telemetry.go @@ -21,6 +21,8 @@ type EventRecorder interface { type CommandRecorder interface { EventRecorder + // RecordDeferred schedules an event to be resolved when telemetry flushes. + RecordDeferred(func() Event) SetSampleRate(rate int) } diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go index 4eb22e898a5..91997c32cd3 100644 --- a/internal/telemetry/fake.go +++ b/internal/telemetry/fake.go @@ -20,6 +20,7 @@ func (r *EventRecorderSpy) Flush() {} type CommandRecorderSpy struct { Events []ghtelemetry.Event LastSampleRate int + deferredEvents []func() ghtelemetry.Event } func (r *CommandRecorderSpy) Record(event ghtelemetry.Event) { @@ -28,8 +29,17 @@ func (r *CommandRecorderSpy) Record(event ghtelemetry.Event) { func (r *CommandRecorderSpy) Disable() {} +func (r *CommandRecorderSpy) RecordDeferred(event func() ghtelemetry.Event) { + r.deferredEvents = append(r.deferredEvents, event) +} + func (r *CommandRecorderSpy) SetSampleRate(rate int) { r.LastSampleRate = rate } -func (r *CommandRecorderSpy) Flush() {} +func (r *CommandRecorderSpy) Flush() { + for _, event := range r.deferredEvents { + r.Events = append(r.Events, event()) + } + r.deferredEvents = nil +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 3943060b124..b38b8988918 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -247,8 +247,9 @@ func NewService(flusher func(SendTelemetryPayload), opts ...telemetryServiceOpti } type recordedEvent struct { - event ghtelemetry.Event - recordedAt time.Time + event ghtelemetry.Event + deferredEvent func() ghtelemetry.Event + recordedAt time.Time } type service struct { @@ -279,6 +280,13 @@ func (s *service) Record(event ghtelemetry.Event) { s.events = append(s.events, recordedEvent{event: event, recordedAt: time.Now()}) } +func (s *service) RecordDeferred(event func() ghtelemetry.Event) { + s.mu.Lock() + defer s.mu.Unlock() + + s.events = append(s.events, recordedEvent{deferredEvent: event, recordedAt: time.Now()}) +} + func (s *service) SetSampleRate(rate int) { s.mu.Lock() defer s.mu.Unlock() @@ -315,16 +323,21 @@ func (s *service) Flush() { } for i, recorded := range events { + event := recorded.event + if recorded.deferredEvent != nil { + event = recorded.deferredEvent() + } + 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) + maps.Copy(dimensions, event.Dimensions) payload.Events[i] = PayloadEvent{ - Type: recorded.event.Type, + Type: event.Type, Dimensions: dimensions, - Measures: recorded.event.Measures, + Measures: event.Measures, } } @@ -418,6 +431,8 @@ type NoOpService struct{} func (s *NoOpService) Record(event ghtelemetry.Event) {} +func (s *NoOpService) RecordDeferred(event func() ghtelemetry.Event) {} + func (s *NoOpService) Disable() {} func (s *NoOpService) SetSampleRate(rate int) {} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 98180a1263c..5ab191147bb 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -421,6 +421,27 @@ func TestServiceFlush(t *testing.T) { assert.Equal(t, int64(150), event.Measures["duration_ms"]) }) + t.Run("resolves deferred events when flushing", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + measure := int64(0) + svc.RecordDeferred(func() ghtelemetry.Event { + return ghtelemetry.Event{ + Type: "deferred", + Measures: ghtelemetry.Measures{"count": measure}, + } + }) + measure = 2 + + svc.Flush() + + require.Len(t, captured.Events, 1) + assert.Equal(t, "deferred", captured.Events[0].Type) + assert.Equal(t, int64(2), captured.Events[0].Measures["count"]) + }) + t.Run("flushes multiple events", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index 3c972d14f20..db25c76e7bb 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -59,8 +59,6 @@ func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru `), Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - opts.AttachFlag.RecordTelemetry(cmd.CommandPath(), telemetry) - opts.RetrieveCommentable = func() (prShared.Commentable, ghrepo.Interface, error) { // TODO wm: more testing issueNumber, parsedBaseRepo, err := shared.ParseIssueFromArg(args[0]) @@ -136,6 +134,8 @@ func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru cmd.Flags().BoolVar(&opts.DeleteLastConfirmed, "yes", false, "Skip the delete confirmation prompt when --delete-last is provided") cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last") opts.AttachFlag = attachments.AddFlag(cmd) + opts.AttachTelemetry = attachments.NewInvocationTelemetry(opts.AttachFlag, telemetry) + cmd.Args = opts.AttachTelemetry.WrapArgs(cmd.Args) return cmd } diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index 08ad22e7757..956df0c78af 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -275,6 +275,12 @@ func TestNewCmdComment(t *testing.T) { isTTY: false, wantsErr: false, }, + { + name: "--attach telemetry survives argument validation", + input: fmt.Sprintf("--attach '%s'", tmpImage), + isTTY: false, + wantsErr: true, + }, { name: "--attach with --body keeps the body and records that one was given", input: fmt.Sprintf("1 --body test --attach '%s'", tmpImage), @@ -423,6 +429,7 @@ func TestNewCmdComment(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) _, err = cmd.ExecuteC() + recorder.Flush() if cmd.Flags().Changed("attach") { values, flagErr := cmd.Flags().GetStringArray("attach") require.NoError(t, flagErr) @@ -430,7 +437,11 @@ func TestNewCmdComment(t *testing.T) { require.Len(t, recorder.Events, 1) assert.Equal(t, "attachment_invocation", recorder.Events[0].Type) assert.Equal(t, cmd.CommandPath(), recorder.Events[0].Dimensions["command"]) - assert.Equal(t, int64(len(values)), recorder.Events[0].Measures["attach_count"]) + assert.Equal(t, ghtelemetry.Measures{ + "attach_count": int64(len(values)), + "append_ops_count": 0, + "replace_ops_count": 0, + }, recorder.Events[0].Measures) } else { assert.Empty(t, recorder.Events) assert.Zero(t, recorder.LastSampleRate) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index c0e6adfa481..3954bef6f2e 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -58,8 +58,9 @@ type CreateOptions struct { BlockedBy []string Blocking []string - AttachFlag *attachments.Flag - Assets []attachments.UserAsset + AttachFlag *attachments.Flag + AttachTelemetry *attachments.InvocationTelemetry + Assets []attachments.UserAsset } func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*CreateOptions) error) *cobra.Command { @@ -121,8 +122,6 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, run Args: cmdutil.NoArgsQuoteReminder, Aliases: []string{"new"}, RunE: func(cmd *cobra.Command, args []string) error { - opts.AttachFlag.RecordTelemetry(cmd.CommandPath(), telemetry) - // support `-R, --repo` override opts.BaseRepo = f.BaseRepo opts.HasRepoOverride = cmd.Flags().Changed("repo") @@ -194,6 +193,8 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, run cmd.Flags().StringSliceVar(&opts.BlockedBy, "blocked-by", nil, "Mark the new issue as blocked by these issue `numbers` or URLs") cmd.Flags().StringSliceVar(&opts.Blocking, "blocking", nil, "Mark the new issue as blocking these issue `numbers` or URLs") opts.AttachFlag = attachments.AddFlag(cmd) + opts.AttachTelemetry = attachments.NewInvocationTelemetry(opts.AttachFlag, telemetry) + cmd.Args = opts.AttachTelemetry.WrapArgs(cmd.Args) return cmd } @@ -466,8 +467,9 @@ func createRun(opts *CreateOptions) (err error) { // not exist, so no issue is created. Once anything has uploaded the // issue is created and the failures are reported. if uploader != nil { - body, uploaded, uploadErr := uploader.UploadAndAttach(context.Background(), tb.Body, opts.Assets) - if uploadErr != nil && uploaded == 0 { + body, uploadResult, uploadErr := uploader.UploadAndAttach(context.Background(), tb.Body, opts.Assets) + opts.AttachTelemetry.RecordOperations(uploadResult) + if uploadErr != nil && uploadResult.Uploaded == 0 { err = uploadErr return } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index e0463fb9504..d131964a9bd 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -276,6 +276,12 @@ func TestNewCmdCreate(t *testing.T) { }, wantAssetPaths: []string{tmpImage}, }, + { + name: "attach telemetry survives argument validation", + tty: false, + cli: fmt.Sprintf(`unexpected --attach '%s'`, tmpImage), + wantsErr: true, + }, { name: "attach conflict is reported before a missing file", tty: false, @@ -325,6 +331,7 @@ func TestNewCmdCreate(t *testing.T) { cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) _, err = cmd.ExecuteC() + recorder.Flush() if cmd.Flags().Changed("attach") { values, flagErr := cmd.Flags().GetStringArray("attach") require.NoError(t, flagErr) @@ -332,7 +339,11 @@ func TestNewCmdCreate(t *testing.T) { require.Len(t, recorder.Events, 1) assert.Equal(t, "attachment_invocation", recorder.Events[0].Type) assert.Equal(t, cmd.CommandPath(), recorder.Events[0].Dimensions["command"]) - assert.Equal(t, int64(len(values)), recorder.Events[0].Measures["attach_count"]) + assert.Equal(t, ghtelemetry.Measures{ + "attach_count": int64(len(values)), + "append_ops_count": 0, + "replace_ops_count": 0, + }, recorder.Events[0].Measures) } else { assert.Empty(t, recorder.Events) assert.Zero(t, recorder.LastSampleRate) @@ -376,17 +387,18 @@ func TestNewCmdCreate(t *testing.T) { func Test_createRun(t *testing.T) { tests := []struct { - name string - opts CreateOptions - attach []string - host string - config string - httpStubs func(*testing.T, *httpmock.Registry) - promptStubs func(*testing.T, *prompter.PrompterMock) - wantsStdout string - wantsStderr string - wantsBrowse string - wantsErr string + name string + opts CreateOptions + attach []string + host string + config string + httpStubs func(*testing.T, *httpmock.Registry) + promptStubs func(*testing.T, *prompter.PrompterMock) + wantsStdout string + wantsStderr string + wantsBrowse string + wantsErr string + wantOperations *attachments.UploadResult }{ { name: "no args", @@ -596,6 +608,10 @@ func Test_createRun(t *testing.T) { attach: []string{"shot.png"}, wantsStdout: "https://github.com/OWNER/REPO/issues/12\n", wantsStderr: "\nCreating issue in OWNER/REPO\n\n", + wantOperations: &attachments.UploadResult{ + Uploaded: 1, + ReplaceOperations: 1, + }, }, { name: "editor and template", @@ -1186,6 +1202,10 @@ func Test_createRun(t *testing.T) { })) }, wantsErr: "could not upload ./second.png: attaching files requires write access to the repository", + wantOperations: &attachments.UploadResult{ + Uploaded: 1, + AppendOperations: 1, + }, }, { name: "a create that fails after an upload failed reports both", @@ -1415,6 +1435,10 @@ func Test_createRun(t *testing.T) { if len(tt.attach) > 0 { opts.Assets = attachments.NewTestAssets(t, tt.attach...) } + attachmentRecorder := &telemetry.CommandRecorderSpy{} + if tt.wantOperations != nil { + opts.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(tt.attach)) + } opts.Config = func() (gh.Config, error) { cfg := tt.config if cfg == "" { @@ -1424,6 +1448,10 @@ func Test_createRun(t *testing.T) { } err := createRun(opts) + if tt.wantOperations != nil { + attachmentRecorder.Flush() + attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) + } if tt.wantsErr == "" { require.NoError(t, err) } else { diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 3b8557abee6..cc6159a0c35 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -51,9 +51,10 @@ type EditOptions struct { AddBlocking []string RemoveBlocking []string - AttachFlag *attachments.Flag - Assets []attachments.UserAsset - Config func() (gh.Config, error) + AttachFlag *attachments.Flag + AttachTelemetry *attachments.InvocationTelemetry + Assets []attachments.UserAsset + Config func() (gh.Config, error) prShared.Editable } @@ -123,8 +124,6 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF `), Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.AttachFlag.RecordTelemetry(cmd.CommandPath(), telemetry) - issueNumbers, baseRepo, err := issueShared.ParseIssuesFromArgs(args) if err != nil { return err @@ -274,6 +273,8 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF cmd.Flags().StringSliceVar(&opts.AddBlocking, "add-blocking", nil, "Add 'blocking' relationships by issue `number` or URL") cmd.Flags().StringSliceVar(&opts.RemoveBlocking, "remove-blocking", nil, "Remove 'blocking' relationships by issue `number` or URL") opts.AttachFlag = attachments.AddFlag(cmd) + opts.AttachTelemetry = attachments.NewInvocationTelemetry(opts.AttachFlag, telemetry) + cmd.Args = opts.AttachTelemetry.WrapArgs(cmd.Args) return cmd } @@ -418,10 +419,11 @@ func editRun(opts *EditOptions) error { // This sits outside the loop below so each file uploads once, and // Clone carries the merged body into the issue. Nothing that can // prompt or cancel may follow an upload. - var uploaded int - body, uploaded, uploadErr = uploader.UploadAndAttach(context.Background(), body, opts.Assets) + var uploadResult attachments.UploadResult + body, uploadResult, uploadErr = uploader.UploadAndAttach(context.Background(), body, opts.Assets) + opts.AttachTelemetry.RecordOperations(uploadResult) - if uploaded > 0 { + if uploadResult.Uploaded > 0 { editable.Body.Value = body editable.Body.Edited = true } else { diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 5e80ddc57a3..9562aece9f1 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -409,6 +409,12 @@ func TestNewCmdEdit(t *testing.T) { }, wantAssetPaths: []string{tmpImage}, }, + { + name: "attach telemetry survives argument validation", + input: fmt.Sprintf("--attach '%s'", tmpImage), + wantsErr: true, + wantsErrMsg: "requires at least 1 arg(s), only received 0", + }, { name: "attach flag beside another edit flag", input: fmt.Sprintf("23 --add-label bug --attach '%s'", tmpImage), @@ -470,6 +476,7 @@ func TestNewCmdEdit(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) _, err = cmd.ExecuteC() + recorder.Flush() if cmd.Flags().Changed("attach") { values, flagErr := cmd.Flags().GetStringArray("attach") require.NoError(t, flagErr) @@ -477,7 +484,11 @@ func TestNewCmdEdit(t *testing.T) { require.Len(t, recorder.Events, 1) assert.Equal(t, "attachment_invocation", recorder.Events[0].Type) assert.Equal(t, cmd.CommandPath(), recorder.Events[0].Dimensions["command"]) - assert.Equal(t, int64(len(values)), recorder.Events[0].Measures["attach_count"]) + assert.Equal(t, ghtelemetry.Measures{ + "attach_count": int64(len(values)), + "append_ops_count": 0, + "replace_ops_count": 0, + }, recorder.Events[0].Measures) } else { assert.Empty(t, recorder.Events) assert.Zero(t, recorder.LastSampleRate) @@ -547,6 +558,7 @@ func Test_editRun(t *testing.T) { // Used instead of wantErrMsg when the subject is which errors survive, // leaving their wording to the layer that formats them. wantErrContains []string + wantOperations *attachments.UploadResult }{ { name: "non-interactive", @@ -1379,6 +1391,10 @@ func Test_editRun(t *testing.T) { mockIssueUpdateWithBody(t, reg, "the original body\n\n![shot](https://example.com/1)") }, stdout: "https://github.com/OWNER/REPO/issue/123\n", + wantOperations: &attachments.UploadResult{ + Uploaded: 1, + AppendOperations: 1, + }, }, { name: "a body flag replaces the body the attachment is then appended to", @@ -1533,6 +1549,10 @@ func Test_editRun(t *testing.T) { stdout: "https://github.com/OWNER/REPO/issue/123\n", wantErr: true, wantErrContains: []string{"./second.png"}, + wantOperations: &attachments.UploadResult{ + Uploaded: 1, + AppendOperations: 1, + }, }, { name: "a sole failed upload does not write the body", @@ -1746,6 +1766,10 @@ func Test_editRun(t *testing.T) { if len(tt.attach) > 0 { tt.input.Assets = attachments.NewTestAssets(t, tt.attach...) } + attachmentRecorder := &telemetry.CommandRecorderSpy{} + if tt.wantOperations != nil { + tt.input.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(tt.attach)) + } hostTokens := tt.hostTokens if hostTokens == nil { @@ -1756,6 +1780,10 @@ func Test_editRun(t *testing.T) { } err := editRun(tt.input) + if tt.wantOperations != nil { + attachmentRecorder.Flush() + attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) + } if tt.wantErr { require.Error(t, err) if tt.wantErrMsg != "" { diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go index e7f7d504674..0cf48f2b461 100644 --- a/pkg/cmd/pr/comment/comment.go +++ b/pkg/cmd/pr/comment/comment.go @@ -57,8 +57,6 @@ func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru `), Args: cobra.MaximumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - opts.AttachFlag.RecordTelemetry(cmd.CommandPath(), telemetry) - if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { return cmdutil.FlagErrorf("argument required when using the --repo flag") } @@ -115,6 +113,8 @@ func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru cmd.Flags().BoolVar(&opts.DeleteLastConfirmed, "yes", false, "Skip the delete confirmation prompt when --delete-last is provided") cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last") opts.AttachFlag = attachments.AddFlag(cmd) + opts.AttachTelemetry = attachments.NewInvocationTelemetry(opts.AttachFlag, telemetry) + cmd.Args = opts.AttachTelemetry.WrapArgs(cmd.Args) return cmd } diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go index c09c83cb32d..bdbdb2c589e 100644 --- a/pkg/cmd/pr/comment/comment_test.go +++ b/pkg/cmd/pr/comment/comment_test.go @@ -297,6 +297,12 @@ func TestNewCmdComment(t *testing.T) { isTTY: false, wantsErr: false, }, + { + name: "--attach telemetry survives argument validation", + input: fmt.Sprintf("1 2 --attach '%s'", tmpImage), + isTTY: false, + wantsErr: true, + }, { name: "--attach with --body keeps the body and records that one was given", input: fmt.Sprintf("1 --body test --attach '%s'", tmpImage), @@ -445,6 +451,7 @@ func TestNewCmdComment(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) _, err = cmd.ExecuteC() + recorder.Flush() if cmd.Flags().Changed("attach") { values, flagErr := cmd.Flags().GetStringArray("attach") require.NoError(t, flagErr) @@ -452,7 +459,11 @@ func TestNewCmdComment(t *testing.T) { require.Len(t, recorder.Events, 1) assert.Equal(t, "attachment_invocation", recorder.Events[0].Type) assert.Equal(t, cmd.CommandPath(), recorder.Events[0].Dimensions["command"]) - assert.Equal(t, int64(len(values)), recorder.Events[0].Measures["attach_count"]) + assert.Equal(t, ghtelemetry.Measures{ + "attach_count": int64(len(values)), + "append_ops_count": 0, + "replace_ops_count": 0, + }, recorder.Events[0].Measures) } else { assert.Empty(t, recorder.Events) assert.Zero(t, recorder.LastSampleRate) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index c365fadebab..46e363adab9 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -77,8 +77,9 @@ type CreateOptions struct { DryRun bool - AttachFlag *attachments.Flag - Assets []attachments.UserAsset + AttachFlag *attachments.Flag + AttachTelemetry *attachments.InvocationTelemetry + Assets []attachments.UserAsset } // creationRefs is an interface that provides the necessary information for creating a pull request in the API. @@ -275,8 +276,6 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, run Args: cmdutil.NoArgsQuoteReminder, Aliases: []string{"new"}, RunE: func(cmd *cobra.Command, args []string) error { - opts.AttachFlag.RecordTelemetry(cmd.CommandPath(), telemetry) - opts.Finder = shared.NewFinder(f) opts.TitleProvided = cmd.Flags().Changed("title") @@ -404,6 +403,8 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, run fl.StringVarP(&opts.Template, "template", "T", "", "Template `file` to use as starting body text") fl.BoolVar(&opts.DryRun, "dry-run", false, "Print details instead of creating the PR. May still push git changes.") opts.AttachFlag = attachments.AddFlag(cmd) + opts.AttachTelemetry = attachments.NewInvocationTelemetry(opts.AttachFlag, telemetry) + cmd.Args = opts.AttachTelemetry.WrapArgs(cmd.Args) _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head") @@ -1127,12 +1128,13 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS var uploadErr error if uploader != nil { - body, uploaded, err := uploader.UploadAndAttach(context.Background(), state.Body, opts.Assets) + body, uploadResult, err := uploader.UploadAndAttach(context.Background(), state.Body, opts.Assets) + opts.AttachTelemetry.RecordOperations(uploadResult) // With nothing uploaded, a body that lost the files it was written // around is not what the caller asked to create. The branch is already // pushed by now, so the message says what was not created rather than // claiming the run had no effect. - if err != nil && uploaded == 0 { + if err != nil && uploadResult.Uploaded == 0 { return fmt.Errorf("%w\nno pull request was created", err) } uploadErr = err diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index e6ecc1047a4..559a838f333 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -296,6 +296,11 @@ func TestNewCmdCreate(t *testing.T) { }, wantAssetPaths: []string{tmpImage}, }, + { + name: "attach telemetry survives argument validation", + cli: fmt.Sprintf("unexpected --attach '%s'", tmpImage), + wantsErr: true, + }, { name: "attach rejects a missing file", cli: "--title mytitle --body mybody --attach ./nope.png", @@ -349,6 +354,7 @@ func TestNewCmdCreate(t *testing.T) { cmd.SetOut(stderr) cmd.SetErr(stderr) _, err = cmd.ExecuteC() + recorder.Flush() if cmd.Flags().Changed("attach") { values, flagErr := cmd.Flags().GetStringArray("attach") require.NoError(t, flagErr) @@ -356,7 +362,11 @@ func TestNewCmdCreate(t *testing.T) { require.Len(t, recorder.Events, 1) assert.Equal(t, "attachment_invocation", recorder.Events[0].Type) assert.Equal(t, cmd.CommandPath(), recorder.Events[0].Dimensions["command"]) - assert.Equal(t, int64(len(values)), recorder.Events[0].Measures["attach_count"]) + assert.Equal(t, ghtelemetry.Measures{ + "attach_count": int64(len(values)), + "append_ops_count": 0, + "replace_ops_count": 0, + }, recorder.Events[0].Measures) } else { assert.Empty(t, recorder.Events) assert.Zero(t, recorder.LastSampleRate) @@ -419,6 +429,7 @@ func Test_createRun(t *testing.T) { customBranchConfig bool // Defaults to WRITE, which can upload. repoPermission string + wantOperations *attachments.UploadResult }{ { name: "nontty web", @@ -1776,6 +1787,10 @@ func Test_createRun(t *testing.T) { })) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", + wantOperations: &attachments.UploadResult{ + Uploaded: 1, + ReplaceOperations: 1, + }, }, { @@ -1871,6 +1886,10 @@ func Test_createRun(t *testing.T) { }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", wantErr: "could not upload ./bad.png: attaching files requires write access to the repository", + wantOperations: &attachments.UploadResult{ + Uploaded: 1, + AppendOperations: 1, + }, }, { name: "the only upload failing creates no pull request", @@ -2041,6 +2060,10 @@ func Test_createRun(t *testing.T) { cleanSetup = tt.setup(&opts, t) } defer cleanSetup() + attachmentRecorder := &telemetry.CommandRecorderSpy{} + if tt.wantOperations != nil { + opts.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(opts.Assets)) + } // All tests in this function use github.com behavior opts.Detector = &fd.EnabledDetectorMock{} @@ -2050,6 +2073,10 @@ func Test_createRun(t *testing.T) { } err := createRun(&opts) + if tt.wantOperations != nil { + attachmentRecorder.Flush() + attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(opts.Assets), *tt.wantOperations) + } output := &test.CmdOut{ OutBuf: stdout, ErrBuf: stderr, diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index fb8161f823c..f01544e3f43 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -40,9 +40,10 @@ type EditOptions struct { SelectorArg string Interactive bool - AttachFlag *attachments.Flag - Assets []attachments.UserAsset - Config func() (gh.Config, error) + AttachFlag *attachments.Flag + AttachTelemetry *attachments.InvocationTelemetry + Assets []attachments.UserAsset + Config func() (gh.Config, error) shared.Editable } @@ -135,8 +136,6 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.AttachFlag.RecordTelemetry(cmd.CommandPath(), telemetry) - opts.Finder = shared.NewFinder(f) // support `-R, --repo` override @@ -258,6 +257,8 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the pull request belongs to by `name`") cmd.Flags().BoolVar(&removeMilestone, "remove-milestone", false, "Remove the milestone association from the pull request") opts.AttachFlag = attachments.AddFlag(cmd) + opts.AttachTelemetry = attachments.NewInvocationTelemetry(opts.AttachFlag, telemetry) + cmd.Args = opts.AttachTelemetry.WrapArgs(cmd.Args) _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base") @@ -423,14 +424,15 @@ func editRun(opts *EditOptions) error { } // Nothing that can prompt or cancel may follow this. - var uploaded int - body, uploaded, uploadErr = uploader.UploadAndAttach(context.Background(), body, opts.Assets) + var uploadResult attachments.UploadResult + body, uploadResult, uploadErr = uploader.UploadAndAttach(context.Background(), body, opts.Assets) + opts.AttachTelemetry.RecordOperations(uploadResult) // With nothing uploaded, even a body the caller typed goes unwritten: // its references are still local paths, which render broken. The other // fields are innocent of the upload, so they proceed either way. - editable.Body.Edited = uploaded > 0 - if uploaded > 0 { + editable.Body.Edited = uploadResult.Uploaded > 0 + if uploadResult.Uploaded > 0 { editable.Body.Value = body } diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 98cbd60b2d9..5184f7069bd 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -316,6 +316,11 @@ func TestNewCmdEdit(t *testing.T) { }, wantAssetPaths: []string{tmpImage}, }, + { + name: "attach telemetry survives argument validation", + input: fmt.Sprintf("23 24 --attach '%s'", tmpImage), + wantsErr: true, + }, { name: "attach with body records the body that replaces the old one", input: fmt.Sprintf("23 --body 'a new body' --attach '%s'", tmpImage), @@ -369,6 +374,7 @@ func TestNewCmdEdit(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) _, err = cmd.ExecuteC() + recorder.Flush() if cmd.Flags().Changed("attach") { values, flagErr := cmd.Flags().GetStringArray("attach") require.NoError(t, flagErr) @@ -376,7 +382,11 @@ func TestNewCmdEdit(t *testing.T) { require.Len(t, recorder.Events, 1) assert.Equal(t, "attachment_invocation", recorder.Events[0].Type) assert.Equal(t, cmd.CommandPath(), recorder.Events[0].Dimensions["command"]) - assert.Equal(t, int64(len(values)), recorder.Events[0].Measures["attach_count"]) + assert.Equal(t, ghtelemetry.Measures{ + "attach_count": int64(len(values)), + "append_ops_count": 0, + "replace_ops_count": 0, + }, recorder.Events[0].Measures) } else { assert.Empty(t, recorder.Events) assert.Zero(t, recorder.LastSampleRate) @@ -428,6 +438,7 @@ func Test_editRun(t *testing.T) { stdout string stderr string wantErr string + wantOperations *attachments.UploadResult }{ { name: "non-interactive", @@ -1290,6 +1301,10 @@ func Test_editRun(t *testing.T) { mockPullRequestUpdateWithBody(t, reg, "the original body\n\n![shot](https://example.com/1)") }, stdout: "https://github.com/OWNER/REPO/pull/123\n", + wantOperations: &attachments.UploadResult{ + Uploaded: 1, + AppendOperations: 1, + }, }, { name: "an empty body flag clears the body and leaves the attachment", @@ -1371,6 +1386,10 @@ func Test_editRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/pull/123\n", wantErr: "could not upload ./b.png: attaching files requires write access to the repository", + wantOperations: &attachments.UploadResult{ + Uploaded: 1, + AppendOperations: 1, + }, }, { name: "a sole failed upload leaves the body alone and still edits the title", @@ -1674,6 +1693,10 @@ func Test_editRun(t *testing.T) { if len(tt.attach) > 0 { tt.input.Assets = attachments.NewTestAssets(t, tt.attach...) } + attachmentRecorder := &telemetry.CommandRecorderSpy{} + if tt.wantOperations != nil { + tt.input.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(tt.attach)) + } // The host comes from the pull request the row's finder returns, so // a row can hold a token for a host the config's default is not. @@ -1693,6 +1716,10 @@ func Test_editRun(t *testing.T) { tt.input.Finder = fieldCapturingFinder{PRFinder: tt.input.Finder, fields: &lookupFields} err := editRun(tt.input) + if tt.wantOperations != nil { + attachmentRecorder.Flush() + attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) + } if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) } else { diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go index db241e5e064..bd1e9a18d43 100644 --- a/pkg/cmd/pr/shared/commentable.go +++ b/pkg/cmd/pr/shared/commentable.go @@ -61,6 +61,7 @@ type CommentableOptions struct { BodyProvided bool KeepExistingBody bool AttachFlag *attachments.Flag + AttachTelemetry *attachments.InvocationTelemetry Assets []attachments.UserAsset Config func() (gh.Config, error) } @@ -353,8 +354,9 @@ func bodyForWrite(opts *CommentableOptions, uploader *attachments.Uploader) (bod if uploader == nil { return opts.Body, true, nil } - body, uploaded, err := uploader.UploadAndAttach(context.Background(), opts.Body, opts.Assets) - if err != nil && uploaded == 0 { + body, uploadResult, err := uploader.UploadAndAttach(context.Background(), opts.Body, opts.Assets) + opts.AttachTelemetry.RecordOperations(uploadResult) + if err != nil && uploadResult.Uploaded == 0 { return "", false, err } return body, true, err diff --git a/pkg/cmd/pr/shared/commentable_test.go b/pkg/cmd/pr/shared/commentable_test.go index 41629dae092..6d04752bc5a 100644 --- a/pkg/cmd/pr/shared/commentable_test.go +++ b/pkg/cmd/pr/shared/commentable_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/v2/internal/config" "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/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" @@ -189,6 +190,7 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { wantStdout string wantErr string wantUploads int + wantOperations *attachments.UploadResult }{ { name: "creating with no asset uploads nothing", @@ -207,6 +209,10 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { wantBody: "see below\n\n![shot](https://example.com/1)", wantStdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", wantUploads: 1, + wantOperations: &attachments.UploadResult{ + Uploaded: 1, + AppendOperations: 1, + }, }, { name: "creating writes what uploaded when one upload fails", @@ -222,6 +228,10 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { wantStdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", wantErr: "could not upload ./b.png: attaching files requires write access to the repository", wantUploads: 2, + wantOperations: &attachments.UploadResult{ + Uploaded: 1, + AppendOperations: 1, + }, }, { name: "creating writes nothing when every upload fails", @@ -231,6 +241,7 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { uploads: []attachments.UploadStub{{Name: "a.png", Status: 404, Body: `{"message":"Not Found"}`}}, wantErr: "could not upload ./a.png: attaching files requires write access to the repository\nno comment was posted", wantUploads: 1, + wantOperations: &attachments.UploadResult{}, }, { name: "creating writes nothing when every upload fails and the body has text", @@ -247,6 +258,7 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { attach: []string{"repro.mp4"}, repositoryDatabaseID: 1234, wantErr: "cannot embed a video as a reference-style image: ./repro.mp4\nno comment was posted", + wantOperations: &attachments.UploadResult{}, }, { name: "creating reports the upload and the write when both fail", @@ -261,6 +273,10 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { writeFails: true, wantErr: "could not upload ./b.png: attaching files requires write access to the repository\nGraphQL: the write failed", wantUploads: 2, + wantOperations: &attachments.UploadResult{ + Uploaded: 1, + AppendOperations: 1, + }, }, { name: "editing keeps the comment when no body flag was given", @@ -435,6 +451,10 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { if len(tt.attach) > 0 { opts.Assets = attachments.NewTestAssets(t, tt.attach...) } + attachmentRecorder := &telemetry.CommandRecorderSpy{} + if tt.wantOperations != nil { + opts.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(tt.attach)) + } host := tt.host if host == "" { @@ -474,6 +494,10 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { } err := CommentableRun(&opts) + if tt.wantOperations != nil { + attachmentRecorder.Flush() + attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) + } if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) From 5e078edf27c40d9f861c010843155b3b13bbcbbb Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 8 Sep 2026 16:01:49 +0200 Subject: [PATCH 02/22] finalize telemetry at invocation completion Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- cmd/gen-docs/main.go | 2 +- internal/attachments/flags_test.go | 6 +- internal/attachments/telemetry.go | 16 +- internal/gh/ghtelemetry/telemetry.go | 16 +- internal/ghcmd/cmd.go | 33 +- internal/telemetry/delivery.go | 34 + internal/telemetry/fake.go | 65 +- internal/telemetry/invocation.go | 241 +++++++ internal/telemetry/invocation_test.go | 172 +++++ internal/telemetry/telemetry.go | 178 +----- internal/telemetry/telemetry_test.go | 599 +++++++++--------- pkg/cmd/api/api_test.go | 6 +- .../verify/verify_integration_test.go | 8 +- pkg/cmd/factory/default_test.go | 4 +- pkg/cmd/issue/comment/comment_test.go | 2 +- pkg/cmd/issue/create/create_test.go | 6 +- pkg/cmd/issue/edit/edit_test.go | 4 +- pkg/cmd/pr/comment/comment_test.go | 2 +- pkg/cmd/pr/create/create_test.go | 4 +- pkg/cmd/pr/edit/edit_test.go | 4 +- pkg/cmd/pr/shared/commentable_test.go | 2 +- pkg/cmd/root/extension_registration_test.go | 2 +- pkg/cmd/root/help_test.go | 2 +- pkg/cmd/skills/install/install_test.go | 22 +- pkg/cmd/skills/list/list_test.go | 3 +- pkg/cmd/skills/preview/preview_test.go | 26 +- pkg/cmd/skills/search/search_test.go | 4 +- pkg/cmdutil/telemetry.go | 44 +- pkg/cmdutil/telemetry_test.go | 297 ++++++--- 29 files changed, 1132 insertions(+), 672 deletions(-) create mode 100644 internal/telemetry/delivery.go create mode 100644 internal/telemetry/invocation.go create mode 100644 internal/telemetry/invocation_test.go diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index d6a317f595f..2250582ddef 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -54,7 +54,7 @@ func run(args []string) error { return config.NewMockConfigFromString(""), nil }, ExtensionManager: &em{}, - }, &telemetry.NoOpService{}, "", "") + }, &telemetry.NoOpInvocation{}, "", "") rootCmd.InitDefaultHelpCmd() if err := os.MkdirAll(*dir, 0755); err != nil { diff --git a/internal/attachments/flags_test.go b/internal/attachments/flags_test.go index 8a8b8b3fb07..34c0d227af9 100644 --- a/internal/attachments/flags_test.go +++ b/internal/attachments/flags_test.go @@ -158,8 +158,8 @@ func TestInvocationTelemetry(t *testing.T) { _, err := attachFlag.UserAssets() require.EqualError(t, err, tt.wantValidationErr) } - recorder.Flush() - recorder.Flush() + recorder.Finish() + recorder.Finish() if !tt.wantEvent { assert.Empty(t, recorder.Events) @@ -214,7 +214,7 @@ func TestInvocationTelemetryWrapArgsRecordsBeforePersistentPreRunError(t *testin _, err := root.ExecuteC() require.EqualError(t, err, "authentication failed") - recorder.Flush() + recorder.Finish() require.Equal(t, []ghtelemetry.Event{{ Type: "attachment_invocation", diff --git a/internal/attachments/telemetry.go b/internal/attachments/telemetry.go index 6a45086c667..1e952fdcece 100644 --- a/internal/attachments/telemetry.go +++ b/internal/attachments/telemetry.go @@ -10,7 +10,7 @@ import ( type InvocationTelemetry struct { flag *Flag recorder ghtelemetry.CommandRecorder - event *ghtelemetry.Event + event ghtelemetry.PendingEvent } // NewInvocationTelemetry creates attachment telemetry for flag. @@ -39,7 +39,8 @@ func (t *InvocationTelemetry) start(command string) { return } - event := &ghtelemetry.Event{ + t.recorder.SetSampleRate(ghtelemetry.SAMPLE_ALL) + t.event = t.recorder.BeginEvent(ghtelemetry.Event{ Type: "attachment_invocation", Dimensions: ghtelemetry.Dimensions{ "command": command, @@ -49,11 +50,6 @@ func (t *InvocationTelemetry) start(command string) { "append_ops_count": 0, "replace_ops_count": 0, }, - } - t.event = event - t.recorder.SetSampleRate(ghtelemetry.SAMPLE_ALL) - t.recorder.RecordDeferred(func() ghtelemetry.Event { - return *event }) } @@ -63,6 +59,8 @@ func (t *InvocationTelemetry) RecordOperations(result UploadResult) { return } - t.event.Measures["append_ops_count"] = int64(result.AppendOperations) - t.event.Measures["replace_ops_count"] = int64(result.ReplaceOperations) + t.event.SetMeasures(ghtelemetry.Measures{ + "append_ops_count": int64(result.AppendOperations), + "replace_ops_count": int64(result.ReplaceOperations), + }) } diff --git a/internal/gh/ghtelemetry/telemetry.go b/internal/gh/ghtelemetry/telemetry.go index 8f85fb64657..28567417cba 100644 --- a/internal/gh/ghtelemetry/telemetry.go +++ b/internal/gh/ghtelemetry/telemetry.go @@ -10,6 +10,13 @@ type Event struct { Measures Measures } +// PendingEvent accepts additional facts until its invocation finishes. +// Setters copy their input and have no effect after completion. +type PendingEvent interface { + SetDimensions(Dimensions) + SetMeasures(Measures) +} + type Disabler interface { Disable() } @@ -21,14 +28,15 @@ type EventRecorder interface { type CommandRecorder interface { EventRecorder - // RecordDeferred schedules an event to be resolved when telemetry flushes. - RecordDeferred(func() Event) + // BeginEvent records initial facts that can be updated until invocation completion. + BeginEvent(Event) PendingEvent SetSampleRate(rate int) } -type Service interface { +// Invocation collects telemetry throughout command execution and completes it once. +type Invocation interface { CommandRecorder - Flush() + Finish() } const SAMPLE_ALL = 100 diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index ed4e1d0b574..73823eb2e99 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -83,34 +83,36 @@ func Main() exitCode { "spinner_disabled": strconv.FormatBool(ioStreams.GetSpinnerDisabled()), } - var telemetryService ghtelemetry.Service + var invocation ghtelemetry.Invocation + var delivery *telemetry.Delivery 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{} + invocation = &telemetry.NoOpInvocation{} default: telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) telemetryDisabled := mightBeGHESUser(cfg) switch telemetryState { case telemetry.Disabled: - telemetryService = &telemetry.NoOpService{} + invocation = &telemetry.NoOpInvocation{} case telemetry.Logged: - // Always construct the real service in log mode so that the log + // Always construct the real invocation 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()), + delivery = telemetry.NewDelivery(telemetry.LogFlusher(ioStreams.ErrOut, ioStreams.ColorEnabled())) + invocation = telemetry.NewInvocation( + delivery, telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), ) if telemetryDisabled { - telemetryService.Disable() + invocation.Disable() } case telemetry.Enabled: if telemetryDisabled { - telemetryService = &telemetry.NoOpService{} + invocation = &telemetry.NoOpInvocation{} break } sampleRate := 1 @@ -118,8 +120,9 @@ func Main() exitCode { sampleRate = v } additionalCommonDimensions["sample_rate"] = strconv.Itoa(sampleRate) - telemetryService = telemetry.NewService( - telemetry.GitHubFlusher(ghExecutablePath), + delivery = telemetry.NewDelivery(telemetry.GitHubFlusher(ghExecutablePath)) + invocation = telemetry.NewInvocation( + delivery, telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), telemetry.WithSampleRate(sampleRate), ) @@ -128,9 +131,13 @@ func Main() exitCode { return exitError } } - defer telemetryService.Flush() + if delivery != nil { + defer delivery.Flush() + } + // Complete events before flushing, including returns before Cobra reaches RunE. + defer invocation.Finish() - cmdFactory := factory.New(buildVersion, string(invokingAgent), cfgFunc, ioStreams, ghExecutablePath, telemetryService) + cmdFactory := factory.New(buildVersion, string(invokingAgent), cfgFunc, ioStreams, ghExecutablePath, invocation) if cfgErr == nil { var m migration.MultiAccount @@ -173,7 +180,7 @@ func Main() exitCode { cobra.MousetrapHelpText = "" } - rootCmd, err := root.NewCmdRoot(cmdFactory, telemetryService, buildVersion, buildDate) + rootCmd, err := root.NewCmdRoot(cmdFactory, invocation, buildVersion, buildDate) if err != nil { fmt.Fprintf(stderr, "failed to create root command: %s\n", err) return exitError diff --git a/internal/telemetry/delivery.go b/internal/telemetry/delivery.go new file mode 100644 index 00000000000..f96c91785f6 --- /dev/null +++ b/internal/telemetry/delivery.go @@ -0,0 +1,34 @@ +package telemetry + +import "sync" + +// Delivery buffers completed invocation payloads until they can be sent. +type Delivery struct { + mu sync.Mutex + send func(SendTelemetryPayload) + payloads []SendTelemetryPayload +} + +// NewDelivery creates a delivery queue using send to transmit each payload. +func NewDelivery(send func(SendTelemetryPayload)) *Delivery { + return &Delivery{send: send} +} + +func (d *Delivery) enqueue(payload SendTelemetryPayload) { + d.mu.Lock() + defer d.mu.Unlock() + + d.payloads = append(d.payloads, payload) +} + +// Flush sends queued payloads. It does not complete in-progress invocations. +func (d *Delivery) Flush() { + d.mu.Lock() + payloads := d.payloads + d.payloads = nil + d.mu.Unlock() + + for _, payload := range payloads { + d.send(payload) + } +} diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go index 91997c32cd3..b9703147f9c 100644 --- a/internal/telemetry/fake.go +++ b/internal/telemetry/fake.go @@ -1,6 +1,10 @@ package telemetry -import "github.com/cli/cli/v2/internal/gh/ghtelemetry" +import ( + "maps" + + "github.com/cli/cli/v2/internal/gh/ghtelemetry" +) type EventRecorderSpy struct { Events []ghtelemetry.Event @@ -12,34 +16,69 @@ 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. +// Finish exposes completed events. LastSampleRate captures the sampling policy +// commands attempt to configure. type CommandRecorderSpy struct { Events []ghtelemetry.Event LastSampleRate int - deferredEvents []func() ghtelemetry.Event + events []*ghtelemetry.Event + finished bool } func (r *CommandRecorderSpy) Record(event ghtelemetry.Event) { - r.Events = append(r.Events, event) + r.BeginEvent(event) } func (r *CommandRecorderSpy) Disable() {} -func (r *CommandRecorderSpy) RecordDeferred(event func() ghtelemetry.Event) { - r.deferredEvents = append(r.deferredEvents, event) +// BeginEvent captures initial facts and returns a handle for subsequent updates. +func (r *CommandRecorderSpy) BeginEvent(event ghtelemetry.Event) ghtelemetry.PendingEvent { + if r.finished { + return noOpPendingEvent{} + } + event = cloneEvent(event) + r.events = append(r.events, &event) + return &pendingEventSpy{recorder: r, event: &event} } func (r *CommandRecorderSpy) SetSampleRate(rate int) { r.LastSampleRate = rate } -func (r *CommandRecorderSpy) Flush() { - for _, event := range r.deferredEvents { - r.Events = append(r.Events, event()) +// Finish snapshots recorded facts into Events once. +func (r *CommandRecorderSpy) Finish() { + if r.finished { + return + } + r.finished = true + for _, event := range r.events { + r.Events = append(r.Events, cloneEvent(*event)) + } + r.events = nil +} + +type pendingEventSpy struct { + recorder *CommandRecorderSpy + event *ghtelemetry.Event +} + +func (p *pendingEventSpy) SetDimensions(dimensions ghtelemetry.Dimensions) { + if p.recorder.finished { + return + } + if p.event.Dimensions == nil { + p.event.Dimensions = make(ghtelemetry.Dimensions) + } + maps.Copy(p.event.Dimensions, dimensions) +} + +func (p *pendingEventSpy) SetMeasures(measures ghtelemetry.Measures) { + if p.recorder.finished { + return + } + if p.event.Measures == nil { + p.event.Measures = make(ghtelemetry.Measures) } - r.deferredEvents = nil + maps.Copy(p.event.Measures, measures) } diff --git a/internal/telemetry/invocation.go b/internal/telemetry/invocation.go new file mode 100644 index 00000000000..505de0a2c3d --- /dev/null +++ b/internal/telemetry/invocation.go @@ -0,0 +1,241 @@ +package telemetry + +import ( + "encoding/binary" + "maps" + "runtime" + "strconv" + "sync" + "time" + + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/google/uuid" +) + +// Invocation owns telemetry facts and reporting policy for one command execution. +// Finish must run after command execution and before delivery is flushed. +type Invocation struct { + mu sync.Mutex + delivery *Delivery + commonDimensions ghtelemetry.Dimensions + sampleRate int + sampleBucket byte + events []*invocationEvent + disabled bool + finished bool +} + +type invocationEvent struct { + event ghtelemetry.Event + recordedAt time.Time +} + +type pendingEvent struct { + invocation *Invocation + recorded *invocationEvent +} + +type invocationOptions struct { + additionalDimensions ghtelemetry.Dimensions + sampleRate int +} + +type invocationOption func(*invocationOptions) + +// WithAdditionalCommonDimensions sets dimensions shared by every invocation event. +func WithAdditionalCommonDimensions(dimensions ghtelemetry.Dimensions) invocationOption { + return func(options *invocationOptions) { + maps.Copy(options.additionalDimensions, dimensions) + } +} + +// WithSampleRate selects invocation-wide sampling. Rates 0 and 100 retain all +// events; rates between them select a percentage using the invocation ID. +func WithSampleRate(rate int) invocationOption { + return func(options *invocationOptions) { + options.sampleRate = rate + } +} + +// NewInvocation creates an invocation whose completed payload is queued for delivery. +func NewInvocation(delivery *Delivery, opts ...invocationOption) *Invocation { + options := invocationOptions{ + additionalDimensions: make(ghtelemetry.Dimensions), + } + for _, opt := range opts { + opt(&options) + } + + deviceID, err := deviceIDFunc() + if err != nil { + deviceID = "" + } + invocationID := uuid.NewString() + commonDimensions := ghtelemetry.Dimensions{ + "device_id": deviceID, + "invocation_id": invocationID, + "os": runtime.GOOS, + "architecture": runtime.GOARCH, + } + maps.Copy(commonDimensions, options.additionalDimensions) + + hash := uuid.NewSHA1(uuid.Nil, []byte(invocationID)) + sampleBucket := byte(binary.BigEndian.Uint32(hash[:4]) % 100) + + return &Invocation{ + delivery: delivery, + commonDimensions: commonDimensions, + sampleRate: options.sampleRate, + sampleBucket: sampleBucket, + } +} + +// Record copies a complete event into the invocation. +// Recording after Finish has no effect. +func (i *Invocation) Record(event ghtelemetry.Event) { + i.mu.Lock() + defer i.mu.Unlock() + + if i.finished { + return + } + i.events = append(i.events, &invocationEvent{ + event: cloneEvent(event), + recordedAt: time.Now(), + }) +} + +// BeginEvent copies an event's initial facts and returns a handle for adding +// facts until Finish. Events begun after Finish are not recorded. +func (i *Invocation) BeginEvent(event ghtelemetry.Event) ghtelemetry.PendingEvent { + i.mu.Lock() + defer i.mu.Unlock() + + if i.finished { + return noOpPendingEvent{} + } + recorded := &invocationEvent{ + event: cloneEvent(event), + recordedAt: time.Now(), + } + i.events = append(i.events, recorded) + return &pendingEvent{invocation: i, recorded: recorded} +} + +func (p *pendingEvent) SetDimensions(dimensions ghtelemetry.Dimensions) { + p.invocation.mu.Lock() + defer p.invocation.mu.Unlock() + + if p.invocation.finished { + return + } + if p.recorded.event.Dimensions == nil { + p.recorded.event.Dimensions = make(ghtelemetry.Dimensions) + } + maps.Copy(p.recorded.event.Dimensions, dimensions) +} + +func (p *pendingEvent) SetMeasures(measures ghtelemetry.Measures) { + p.invocation.mu.Lock() + defer p.invocation.mu.Unlock() + + if p.invocation.finished { + return + } + if p.recorded.event.Measures == nil { + p.recorded.event.Measures = make(ghtelemetry.Measures) + } + maps.Copy(p.recorded.event.Measures, measures) +} + +// SetSampleRate selects the sampling policy for the whole invocation. +// Changes after Finish have no effect. +func (i *Invocation) SetSampleRate(rate int) { + i.mu.Lock() + defer i.mu.Unlock() + + if i.finished { + return + } + i.sampleRate = rate + i.commonDimensions["sample_rate"] = strconv.Itoa(rate) +} + +// Disable suppresses all events in the invocation, including already recorded +// events. It must be called before Finish. +func (i *Invocation) Disable() { + i.mu.Lock() + defer i.mu.Unlock() + + i.disabled = true +} + +// Finish snapshots the invocation once and queues its payload without sending it. +// Sampling and telemetry eligibility apply to immediate and pending events alike. +func (i *Invocation) Finish() { + i.mu.Lock() + defer i.mu.Unlock() + + if i.finished { + return + } + i.finished = true + + if i.sampleRate > 0 && i.sampleRate < 100 && int(i.sampleBucket) >= i.sampleRate { + return + } + + events := i.events + if i.disabled { + events = nil + } + + // Keep an empty payload so log mode can explain that no telemetry will be sent. + payload := SendTelemetryPayload{Events: make([]PayloadEvent, len(events))} + for index, recorded := range events { + dimensions := map[string]string{ + "timestamp": recorded.recordedAt.UTC().Format("2006-01-02T15:04:05.000Z"), + } + maps.Copy(dimensions, i.commonDimensions) + maps.Copy(dimensions, recorded.event.Dimensions) + payload.Events[index] = PayloadEvent{ + Type: recorded.event.Type, + Dimensions: dimensions, + Measures: maps.Clone(recorded.event.Measures), + } + } + i.delivery.enqueue(payload) +} + +func cloneEvent(event ghtelemetry.Event) ghtelemetry.Event { + return ghtelemetry.Event{ + Type: event.Type, + Dimensions: maps.Clone(event.Dimensions), + Measures: maps.Clone(event.Measures), + } +} + +type noOpPendingEvent struct{} + +func (noOpPendingEvent) SetDimensions(ghtelemetry.Dimensions) {} +func (noOpPendingEvent) SetMeasures(ghtelemetry.Measures) {} + +// NoOpInvocation discards telemetry when collection is disabled. +type NoOpInvocation struct{} + +// Record discards the event. +func (*NoOpInvocation) Record(ghtelemetry.Event) {} + +// BeginEvent returns an inert handle without retaining the event. +func (*NoOpInvocation) BeginEvent(ghtelemetry.Event) ghtelemetry.PendingEvent { + return noOpPendingEvent{} +} + +// Disable leaves telemetry disabled. +func (*NoOpInvocation) Disable() {} + +// SetSampleRate leaves telemetry disabled. +func (*NoOpInvocation) SetSampleRate(int) {} + +// Finish has no payload to complete. +func (*NoOpInvocation) Finish() {} diff --git a/internal/telemetry/invocation_test.go b/internal/telemetry/invocation_test.go new file mode 100644 index 00000000000..06e5d9aff95 --- /dev/null +++ b/internal/telemetry/invocation_test.go @@ -0,0 +1,172 @@ +package telemetry + +import ( + "sync" + "testing" + "testing/synctest" + "time" + + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInvocationCopiesFactsAtTheirRecordingTime(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + synctest.Test(t, func(t *testing.T) { + // Given a producer that reuses its event and update maps + var payload SendTelemetryPayload + delivery := NewDelivery(func(p SendTelemetryPayload) { payload = p }) + invocation := NewInvocation(delivery) + facts := ghtelemetry.Event{ + Type: "command_invocation", + Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, + Measures: ghtelemetry.Measures{"count": 1}, + } + startedAt := time.Now() + pending := invocation.BeginEvent(facts) + time.Sleep(time.Second) + facts.Type = "completed_step" + invocation.Record(facts) + + // When the producer updates pending facts and later reuses those maps + facts.Dimensions["command"] = "unrelated" + facts.Measures["count"] = 99 + dimensions := ghtelemetry.Dimensions{"flags": "attach"} + measures := ghtelemetry.Measures{"count": 2} + pending.SetDimensions(dimensions) + pending.SetMeasures(measures) + dimensions["flags"] = "unrelated" + measures["count"] = 99 + time.Sleep(time.Second) + invocation.Finish() + delivery.Flush() + + // Then event order, original timestamps, and independently owned facts survive + require.Len(t, payload.Events, 2) + first, second := payload.Events[0], payload.Events[1] + assert.Equal(t, "command_invocation", first.Type) + assert.Equal(t, "gh issue create", first.Dimensions["command"]) + assert.Equal(t, "attach", first.Dimensions["flags"]) + assert.Equal(t, int64(2), first.Measures["count"]) + assert.Equal(t, startedAt.UTC().Format("2006-01-02T15:04:05.000Z"), first.Dimensions["timestamp"]) + assert.Equal(t, "completed_step", second.Type) + assert.Equal(t, "gh issue create", second.Dimensions["command"]) + assert.Equal(t, int64(1), second.Measures["count"]) + assert.Equal(t, startedAt.Add(time.Second).UTC().Format("2006-01-02T15:04:05.000Z"), second.Dimensions["timestamp"]) + }) +} + +func TestInvocationPromotesAllEventsBeforeCompletion(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + // Given a command that discovers its full-sampling policy after recording facts + var payload SendTelemetryPayload + delivery := NewDelivery(func(p SendTelemetryPayload) { payload = p }) + invocation := NewInvocation(delivery, WithSampleRate(1)) + invocation.Record(ghtelemetry.Event{Type: "completed_step"}) + pending := invocation.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) + + // When attachment usage promotes the invocation before it finishes + invocation.SetSampleRate(ghtelemetry.SAMPLE_ALL) + pending.SetMeasures(ghtelemetry.Measures{"attach_count": 2}) + invocation.Finish() + delivery.Flush() + + // Then immediate and pending events share the promoted sampling policy + require.Len(t, payload.Events, 2) + assert.Equal(t, "completed_step", payload.Events[0].Type) + assert.Equal(t, "attachment_invocation", payload.Events[1].Type) + assert.Equal(t, "100", payload.Events[0].Dimensions["sample_rate"]) + assert.Equal(t, "100", payload.Events[1].Dimensions["sample_rate"]) + assert.Equal(t, payload.Events[0].Dimensions["invocation_id"], payload.Events[1].Dimensions["invocation_id"]) + assert.Equal(t, int64(2), payload.Events[1].Measures["attach_count"]) +} + +func TestInvocationDisablingOverridesPromotedPendingEvents(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + // Given immediate and pending events in a fully sampled invocation + var payloads []SendTelemetryPayload + delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + invocation := NewInvocation(delivery) + invocation.Record(ghtelemetry.Event{Type: "completed_step"}) + pending := invocation.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) + invocation.SetSampleRate(ghtelemetry.SAMPLE_ALL) + + // When host discovery disables telemetry before completion + invocation.Disable() + pending.SetMeasures(ghtelemetry.Measures{"attach_count": 2}) + invocation.Record(ghtelemetry.Event{Type: "another_step"}) + invocation.Finish() + delivery.Flush() + + // Then delivery receives only the empty payload used by log mode + require.Len(t, payloads, 1) + assert.Empty(t, payloads[0].Events) +} + +func TestInvocationCompletionCannotBeReopened(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + // Given a completed invocation with one pending event + var payloads []SendTelemetryPayload + delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + invocation := NewInvocation(delivery) + pending := invocation.BeginEvent(ghtelemetry.Event{ + Type: "command_invocation", + Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, + }) + invocation.Finish() + + // When cleanup repeats or code holding an old handle attempts further recording + pending.SetDimensions(ghtelemetry.Dimensions{"command": "changed"}) + invocation.Record(ghtelemetry.Event{Type: "too_late"}) + late := invocation.BeginEvent(ghtelemetry.Event{Type: "also_too_late"}) + late.SetDimensions(ghtelemetry.Dimensions{"command": "changed"}) + late.SetMeasures(ghtelemetry.Measures{"count": 1}) + invocation.Finish() + delivery.Flush() + invocation.Finish() + delivery.Flush() + + // Then the original snapshot is delivered exactly once + require.Len(t, payloads, 1) + require.Len(t, payloads[0].Events, 1) + assert.Equal(t, "command_invocation", payloads[0].Events[0].Type) + assert.Equal(t, "gh issue create", payloads[0].Events[0].Dimensions["command"]) +} + +func TestInvocationCollectsConcurrentFacts(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + // Given two command activities contributing to the same pending event + var payload SendTelemetryPayload + delivery := NewDelivery(func(p SendTelemetryPayload) { payload = p }) + invocation := NewInvocation(delivery) + pending := invocation.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) + + // When both activities finish before command completion + var workers sync.WaitGroup + workers.Go(func() { + pending.SetDimensions(ghtelemetry.Dimensions{"command": "gh issue create"}) + pending.SetMeasures(ghtelemetry.Measures{"append_ops_count": 1}) + }) + workers.Go(func() { + pending.SetDimensions(ghtelemetry.Dimensions{"flags": "attach"}) + pending.SetMeasures(ghtelemetry.Measures{"replace_ops_count": 2}) + }) + workers.Wait() + invocation.Finish() + delivery.Flush() + + // Then the completed event contains both activities' facts + require.Len(t, payload.Events, 1) + assert.Equal(t, "gh issue create", payload.Events[0].Dimensions["command"]) + assert.Equal(t, "attach", payload.Events[0].Dimensions["flags"]) + assert.Equal(t, map[string]int64{ + "append_ops_count": 1, + "replace_ops_count": 2, + }, payload.Events[0].Measures) +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index b38b8988918..970bff04a08 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -1,26 +1,20 @@ // Package telemetry provides best-effort usage telemetry for gh commands. +// Invocations collect facts until completion; delivery sends completed payloads. package telemetry import ( "bytes" - "encoding/binary" "encoding/json" "errors" "fmt" "io" - "maps" "os" "os/exec" "path/filepath" - "runtime" "slices" - "strconv" "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" @@ -144,29 +138,6 @@ func ParseTelemetryState(configValue string) TelemetryState { 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. // 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) { @@ -209,141 +180,6 @@ var GitHubFlusher = func(executable string) func(payload SendTelemetryPayload) { } } -// 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 := byte(binary.BigEndian.Uint32(hash[:4]) % 100) - - s := &service{ - flush: flusher, - commonDimensions: commonDimensions, - sampleRate: telemetryServiceOpts.sampleRate, - sampleBucket: sampleBucket, - } - - return s -} - -type recordedEvent struct { - event ghtelemetry.Event - deferredEvent func() 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 - - disabled bool -} - -func (s *service) Disable() { - s.mu.Lock() - defer s.mu.Unlock() - - s.disabled = true -} - -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) RecordDeferred(event func() ghtelemetry.Event) { - s.mu.Lock() - defer s.mu.Unlock() - - s.events = append(s.events, recordedEvent{deferredEvent: event, recordedAt: time.Now()}) -} - -func (s *service) SetSampleRate(rate int) { - s.mu.Lock() - defer s.mu.Unlock() - - s.sampleRate = rate - s.commonDimensions["sample_rate"] = strconv.Itoa(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 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(events)), - } - - for i, recorded := range events { - event := recorded.event - if recorded.deferredEvent != nil { - event = recorded.deferredEvent() - } - - dimensions := map[string]string{ - "timestamp": recorded.recordedAt.UTC().Format("2006-01-02T15:04:05.000Z"), - } - maps.Copy(dimensions, s.commonDimensions) - maps.Copy(dimensions, event.Dimensions) - - payload.Events[i] = PayloadEvent{ - Type: event.Type, - Dimensions: dimensions, - Measures: 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). @@ -426,15 +262,3 @@ func SpawnSendTelemetry(executable string, payload SendTelemetryPayload) { // 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) RecordDeferred(event func() 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 5ab191147bb..118b5672a68 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -3,16 +3,15 @@ package telemetry import ( "bytes" "errors" - "maps" "os" "path/filepath" "strings" "sync" "testing" + "testing/synctest" "time" "github.com/cli/cli/v2/internal/gh/ghtelemetry" - "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -41,27 +40,6 @@ func stubLookupEnv(fn func(string) (string, bool)) func() { 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() @@ -317,18 +295,22 @@ func TestParseTelemetryState(t *testing.T) { } } -func TestNewServiceLogModeFlushesToWriter(t *testing.T) { +func TestNewInvocationLogModeFlushesToWriter(t *testing.T) { + // Given an invocation with log delivery t.Cleanup(stubDeviceID("test-device")) - var buf bytes.Buffer - svc := NewService(LogFlusher(&buf, false)) + delivery := NewDelivery(LogFlusher(&buf, false)) + invocation := NewInvocation(delivery) - svc.Record(ghtelemetry.Event{ + // When the invocation finishes and delivery is flushed + invocation.Record(ghtelemetry.Event{ Type: "test_event", Dimensions: map[string]string{"key": "value"}, }) - svc.Flush() + invocation.Finish() + delivery.Flush() + // Then the writer receives the recorded event output := buf.String() assert.Contains(t, output, "Telemetry payload:") assert.Contains(t, output, "test_event") @@ -336,18 +318,21 @@ func TestNewServiceLogModeFlushesToWriter(t *testing.T) { assert.Contains(t, output, `"value"`) } -func TestNewServiceLogModeWithColorLogsToWriter(t *testing.T) { +func TestNewInvocationLogModeWithColorLogsToWriter(t *testing.T) { + // Given an invocation with colored log delivery t.Cleanup(stubDeviceID("test-device")) - var buf bytes.Buffer - svc := NewService(LogFlusher(&buf, true)) + delivery := NewDelivery(LogFlusher(&buf, true)) + invocation := NewInvocation(delivery) - svc.Record(ghtelemetry.Event{Type: "color_event"}) - svc.Flush() + // When the invocation finishes and delivery is flushed + invocation.Record(ghtelemetry.Event{Type: "color_event"}) + invocation.Finish() + delivery.Flush() + // Then the writer receives the event with ANSI color codes 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") } @@ -368,48 +353,97 @@ func TestLogFlusherWritesNoneMarkerForEmptyPayload(t *testing.T) { }) } -func TestServiceDeviceIDFallback(t *testing.T) { - t.Cleanup(stubDeviceIDError(errors.New("no device id"))) +func TestInvocationFinishesPendingEventsBeforeDelivery(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + // Given an invocation whose attachment operations are not yet known + var payloads []SendTelemetryPayload + delivery := NewDelivery(func(payload SendTelemetryPayload) { + payloads = append(payloads, payload) + }) + invocation := NewInvocation(delivery) + event := invocation.BeginEvent(ghtelemetry.Event{ + Type: "attachment_invocation", + Measures: ghtelemetry.Measures{ + "attach_count": 2, + "append_ops_count": 0, + "replace_ops_count": 0, + }, + }) + + // When delivery is flushed before the invocation finishes + delivery.Flush() + require.Empty(t, payloads, "delivery must not finalize an unfinished invocation") + event.SetMeasures(ghtelemetry.Measures{ + "append_ops_count": 1, + "replace_ops_count": 1, + }) + invocation.Finish() + require.Empty(t, payloads, "completion must not send telemetry") + event.SetMeasures(ghtelemetry.Measures{"append_ops_count": 99}) + delivery.Flush() + + // Then delivery contains the snapshot taken at invocation completion + require.Len(t, payloads, 1) + require.Len(t, payloads[0].Events, 1) + assert.Equal(t, "attachment_invocation", payloads[0].Events[0].Type) + assert.Equal(t, map[string]int64{ + "attach_count": 2, + "append_ops_count": 1, + "replace_ops_count": 1, + }, payloads[0].Events[0].Measures) +} +func TestInvocationDeviceIDFallback(t *testing.T) { + // Given device ID discovery fails + t.Cleanup(stubDeviceIDError(errors.New("no device id"))) var captured SendTelemetryPayload - svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) + invocation := NewInvocation(delivery) - svc.Record(ghtelemetry.Event{Type: "test"}) - svc.Flush() + // When a recorded event is completed and delivered + invocation.Record(ghtelemetry.Event{Type: "test"}) + invocation.Finish() + delivery.Flush() + // Then the payload identifies the device as unknown require.Len(t, captured.Events, 1) assert.Equal(t, "", captured.Events[0].Dimensions["device_id"]) } -func TestServiceFlush(t *testing.T) { - t.Run("calls flusher with empty payload when no events recorded", func(t *testing.T) { +func TestInvocationFinish(t *testing.T) { + t.Run("logs none when no events recorded", func(t *testing.T) { + // Given an invocation without events and log delivery t.Cleanup(stubDeviceID("test-device")) + var buf bytes.Buffer + delivery := NewDelivery(LogFlusher(&buf, false)) + invocation := NewInvocation(delivery) - var captured SendTelemetryPayload - called := false - svc := newService(func(p SendTelemetryPayload) { - called = true - captured = p - }, nil) - svc.Flush() - - 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") + // When the invocation finishes and delivery is flushed + invocation.Finish() + delivery.Flush() + + // Then log mode explains the absence of telemetry + assert.Equal(t, "Telemetry payload: none\n", buf.String()) }) - t.Run("flushes events with merged dimensions", func(t *testing.T) { + t.Run("delivers events with merged dimensions", func(t *testing.T) { + // Given an invocation with common dimensions t.Cleanup(stubDeviceID("test-device")) - var captured SendTelemetryPayload - svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{"version": "2.45.0"}) + delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) + invocation := NewInvocation(delivery, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"version": "2.45.0"})) - svc.Record(ghtelemetry.Event{ + // When an event with its own dimensions and measures is completed and delivered + invocation.Record(ghtelemetry.Event{ Type: "command_invocation", Dimensions: map[string]string{"command": "gh pr list"}, Measures: map[string]int64{"duration_ms": 150}, }) - svc.Flush() + invocation.Finish() + delivery.Flush() + // Then the payload includes both common and event-specific facts require.Len(t, captured.Events, 1) event := captured.Events[0] assert.Equal(t, "command_invocation", event.Type) @@ -421,309 +455,298 @@ func TestServiceFlush(t *testing.T) { assert.Equal(t, int64(150), event.Measures["duration_ms"]) }) - t.Run("resolves deferred events when flushing", func(t *testing.T) { + t.Run("delivers multiple events", func(t *testing.T) { + // Given an invocation with two recorded events t.Cleanup(stubDeviceID("test-device")) - var captured SendTelemetryPayload - svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) - measure := int64(0) - svc.RecordDeferred(func() ghtelemetry.Event { - return ghtelemetry.Event{ - Type: "deferred", - Measures: ghtelemetry.Measures{"count": measure}, - } - }) - measure = 2 - - svc.Flush() + delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) + invocation := NewInvocation(delivery) + invocation.Record(ghtelemetry.Event{Type: "event1"}) + invocation.Record(ghtelemetry.Event{Type: "event2"}) - require.Len(t, captured.Events, 1) - assert.Equal(t, "deferred", captured.Events[0].Type) - assert.Equal(t, int64(2), captured.Events[0].Measures["count"]) - }) - - 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() + // When the invocation finishes and delivery is flushed + invocation.Finish() + delivery.Flush() + // Then both events are delivered in recording order 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) { + // Given an invocation with a recorded event 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") + var payloads []SendTelemetryPayload + delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + invocation := NewInvocation(delivery) + invocation.Record(ghtelemetry.Event{Type: "test"}) + + // When completion and delivery are repeated + invocation.Finish() + delivery.Flush() + invocation.Finish() + delivery.Flush() + invocation.Finish() + delivery.Flush() + + // Then the recorded event is delivered exactly once + require.Len(t, payloads, 1) + require.Len(t, payloads[0].Events, 1) + assert.Equal(t, "test", payloads[0].Events[0].Type) }) t.Run("event dimensions override common dimensions", func(t *testing.T) { + // Given common and event dimensions share a key t.Cleanup(stubDeviceID("test-device")) - var captured SendTelemetryPayload - svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{"shared": "common"}) - - svc.Record(ghtelemetry.Event{ + delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) + invocation := NewInvocation(delivery, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"shared": "common"})) + invocation.Record(ghtelemetry.Event{ Type: "test", Dimensions: map[string]string{"shared": "event-level"}, }) - svc.Flush() + // When the invocation finishes and delivery is flushed + invocation.Finish() + delivery.Flush() + + // Then the event dimension takes precedence 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.Run("timestamps reflect record time not completion or delivery 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) + synctest.Test(t, func(t *testing.T) { + // Given events recorded at distinct times + var captured SendTelemetryPayload + delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) + invocation := NewInvocation(delivery) + firstRecordedAt := time.Now() + invocation.Record(ghtelemetry.Event{Type: "early"}) + time.Sleep(50 * time.Millisecond) + secondRecordedAt := time.Now() + invocation.Record(ghtelemetry.Event{Type: "late"}) + + // When completion and delivery each happen later + time.Sleep(time.Second) + invocation.Finish() + time.Sleep(time.Second) + delivery.Flush() + + // Then each timestamp reflects when its event was recorded + require.Len(t, captured.Events, 2) + firstTimestamp, err := time.Parse("2006-01-02T15:04:05.000Z", captured.Events[0].Dimensions["timestamp"]) + require.NoError(t, err) + secondTimestamp, err := time.Parse("2006-01-02T15:04:05.000Z", captured.Events[1].Dimensions["timestamp"]) + require.NoError(t, err) + assert.WithinDuration(t, firstRecordedAt, firstTimestamp, 0) + assert.WithinDuration(t, secondRecordedAt, secondTimestamp, 0) + }) }) } -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") - }) +func TestInvocationSampling(t *testing.T) { + tests := []struct { + name string + sampleRate int + sampleBucket byte + wantPayloads int + }{ + { + name: "sampleRate 0 sends all events", + sampleRate: 0, + sampleBucket: 99, + wantPayloads: 1, + }, + { + name: "sampleRate 100 sends all events regardless of bucket", + sampleRate: 100, + sampleBucket: 99, + wantPayloads: 1, + }, + { + name: "bucket below sampleRate sends events", + sampleRate: 50, + sampleBucket: 49, + wantPayloads: 1, + }, + { + name: "bucket at sampleRate drops events", + sampleRate: 50, + sampleBucket: 50, + wantPayloads: 0, + }, + { + name: "bucket above sampleRate drops events", + sampleRate: 1, + sampleBucket: 50, + wantPayloads: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Given a configured sample rate and a deterministic sampling bucket + t.Cleanup(stubDeviceID("test-device")) + var payloads []SendTelemetryPayload + delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + invocation := NewInvocation(delivery, WithSampleRate(tt.sampleRate)) + // Fix the random bucket so sampling boundaries can be asserted through delivery. + invocation.sampleBucket = tt.sampleBucket + + // When the invocation finishes and delivery is flushed + invocation.Record(ghtelemetry.Event{Type: "test"}) + invocation.Finish() + delivery.Flush() + + // Then only invocations selected by sampling are delivered + require.Len(t, payloads, tt.wantPayloads) + for _, payload := range payloads { + require.Len(t, payload.Events, 1) + assert.Equal(t, "test", payload.Events[0].Type) + } + }) + } +} - t.Run("SetSampleRate changes flush behavior", func(t *testing.T) { +func TestInvocationSetSampleRate(t *testing.T) { + t.Run("changes delivery eligibility", func(t *testing.T) { + // Given an invocation that initially sends all events 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") + var payloads []SendTelemetryPayload + delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + invocation := NewInvocation(delivery, WithSampleRate(0)) + invocation.sampleBucket = 50 + + // When its sample rate excludes the bucket before completion + invocation.SetSampleRate(10) + invocation.Record(ghtelemetry.Event{Type: "test"}) + invocation.Finish() + delivery.Flush() + + // Then no payload is delivered + assert.Empty(t, payloads) }) - t.Run("SetSampleRate updates sample_rate dimension", func(t *testing.T) { + t.Run("updates sample_rate dimension", func(t *testing.T) { + // Given an invocation with an initial sample_rate dimension t.Cleanup(stubDeviceID("test-device")) - var captured SendTelemetryPayload - svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{ - "sample_rate": "1", - }) - svc.sampleRate = 1 - svc.sampleBucket = 0 - - svc.SetSampleRate(100) - svc.Record(ghtelemetry.Event{Type: "test"}) - svc.Flush() - + delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) + invocation := NewInvocation(delivery, + WithSampleRate(1), + WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"sample_rate": "1"}), + ) + + // When the rate changes before completion and delivery + invocation.SetSampleRate(100) + invocation.Record(ghtelemetry.Event{Type: "test"}) + invocation.Finish() + delivery.Flush() + + // Then the payload describes the effective sample rate require.Len(t, captured.Events, 1) assert.Equal(t, "100", captured.Events[0].Dimensions["sample_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) { + // Given an invocation constructed with additional common dimensions t.Cleanup(stubDeviceID("test-device")) - var captured SendTelemetryPayload - svc := NewService( - func(p SendTelemetryPayload) { captured = p }, + delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) + invocation := NewInvocation( + delivery, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{ "version": "2.45.0", "agent": "none", }), ) - svc.Record(ghtelemetry.Event{Type: "test"}) - svc.Flush() + // When a recorded event is completed and delivered + invocation.Record(ghtelemetry.Event{Type: "test"}) + invocation.Finish() + delivery.Flush() + // Then both additional and standard dimensions are present 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 TestServiceDisable(t *testing.T) { - t.Run("drops recorded events from flushed payload", func(t *testing.T) { +func TestInvocationDisable(t *testing.T) { + t.Run("drops recorded events from delivered payload", func(t *testing.T) { + // Given an invocation with a recorded event t.Cleanup(stubDeviceID("test-device")) - - var captured SendTelemetryPayload - called := false - svc := newService(func(p SendTelemetryPayload) { - called = true - captured = p - }, nil) - - svc.Record(ghtelemetry.Event{Type: "test"}) - svc.Disable() - svc.Flush() - - 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()") + var payloads []SendTelemetryPayload + delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + invocation := NewInvocation(delivery) + invocation.Record(ghtelemetry.Event{Type: "test"}) + + // When telemetry is disabled before completion and delivery + invocation.Disable() + invocation.Finish() + delivery.Flush() + + // Then an empty payload is delivered so log mode can surface the absence + require.Len(t, payloads, 1) + assert.Empty(t, payloads[0].Events, "recorded events should be dropped after Disable()") }) t.Run("drops events even with multiple recorded events", func(t *testing.T) { + // Given an invocation with multiple recorded events t.Cleanup(stubDeviceID("test-device")) - - var captured SendTelemetryPayload - called := false - svc := newService(func(p SendTelemetryPayload) { - called = true - captured = p - }, nil) - - svc.Record(ghtelemetry.Event{Type: "event1"}) - svc.Record(ghtelemetry.Event{Type: "event2"}) - svc.Record(ghtelemetry.Event{Type: "event3"}) - svc.Disable() - svc.Flush() - - assert.True(t, called, "flusher should still be called") - assert.Empty(t, captured.Events, "recorded events should be dropped after Disable()") + var payloads []SendTelemetryPayload + delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + invocation := NewInvocation(delivery) + invocation.Record(ghtelemetry.Event{Type: "event1"}) + invocation.Record(ghtelemetry.Event{Type: "event2"}) + invocation.Record(ghtelemetry.Event{Type: "event3"}) + + // When telemetry is disabled before completion and delivery + invocation.Disable() + invocation.Finish() + delivery.Flush() + + // Then none of the recorded events appear in the delivered payload + require.Len(t, payloads, 1) + assert.Empty(t, payloads[0].Events, "recorded events should be dropped after Disable()") }) t.Run("can be called before any events are recorded", func(t *testing.T) { + // Given an invocation disabled before any events are recorded t.Cleanup(stubDeviceID("test-device")) - - var captured SendTelemetryPayload - called := false - svc := newService(func(p SendTelemetryPayload) { - called = true - captured = p - }, nil) - - svc.Disable() - svc.Record(ghtelemetry.Event{Type: "test"}) - svc.Flush() - - assert.True(t, called, "flusher should still be called") - assert.Empty(t, captured.Events, "events recorded after Disable() should be dropped") + var payloads []SendTelemetryPayload + delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + invocation := NewInvocation(delivery) + invocation.Disable() + + // When an event is recorded and the invocation completes + invocation.Record(ghtelemetry.Event{Type: "test"}) + invocation.Finish() + delivery.Flush() + + // Then the later event is excluded from the delivered payload + require.Len(t, payloads, 1) + assert.Empty(t, payloads[0].Events, "events recorded after Disable() should be dropped") }) } -func TestNoOpService(t *testing.T) { - svc := &NoOpService{} +func TestNoOpInvocation(t *testing.T) { + invocation := &NoOpInvocation{} // All methods should be safe to call without panicking - svc.Record(ghtelemetry.Event{Type: "test"}) - svc.Disable() - svc.SetSampleRate(50) - svc.Flush() + invocation.Record(ghtelemetry.Event{Type: "test"}) + event := invocation.BeginEvent(ghtelemetry.Event{Type: "pending"}) + event.SetDimensions(ghtelemetry.Dimensions{"key": "value"}) + event.SetMeasures(ghtelemetry.Measures{"count": 1}) + invocation.Disable() + invocation.SetSampleRate(50) + invocation.Finish() } func TestSpawnSendTelemetryRejectsOversizedPayload(t *testing.T) { diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index e67ca517a76..9a08e6798cb 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -433,9 +433,10 @@ func TestNewCmdApiTelemetry(t *testing.T) { t.Cleanup(server.Close) var payload telemetry.SendTelemetryPayload - recorder := telemetry.NewService(func(p telemetry.SendTelemetryPayload) { + delivery := telemetry.NewDelivery(func(p telemetry.SendTelemetryPayload) { payload = p }) + recorder := telemetry.NewInvocation(delivery) recorder.Record(ghtelemetry.Event{Type: "command"}) ios, _, _, _ := iostreams.Test() @@ -449,7 +450,8 @@ func TestNewCmdApiTelemetry(t *testing.T) { _, err := cmd.ExecuteC() require.NoError(t, err) - recorder.Flush() + recorder.Finish() + delivery.Flush() assert.Empty(t, payload.Events) } diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index 137880e6f63..f2b51d5fec0 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -36,7 +36,7 @@ func TestVerifyIntegration(t *testing.T) { ios, "test", "", - &telemetry.NoOpService{}, + &telemetry.NoOpInvocation{}, )() require.NoError(t, err) @@ -156,7 +156,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { ios, "test", "", - &telemetry.NoOpService{}, + &telemetry.NoOpInvocation{}, )() require.NoError(t, err) @@ -234,7 +234,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { ios, "test", "", - &telemetry.NoOpService{}, + &telemetry.NoOpInvocation{}, )() require.NoError(t, err) @@ -331,7 +331,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { ios, "test", "", - &telemetry.NoOpService{}, + &telemetry.NoOpInvocation{}, )() require.NoError(t, err) diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index c41b77506d4..43df914c2ca 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.NewMockConfig() ios, _, _, stderr := iostreams.Test() - client, err := HttpClientFunc(func() (gh.Config, error) { return cfg, nil }, ios, "v1.2.3", "", &telemetry.NoOpService{})() + client, err := HttpClientFunc(func() (gh.Config, error) { return cfg, nil }, ios, "v1.2.3", "", &telemetry.NoOpInvocation{})() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) if tt.sso != "" { @@ -383,7 +383,7 @@ func TestPlainHttpClient(t *testing.T) { defer ts.Close() ios, _, _, _ := iostreams.Test() - client, err := plainHttpClientFunc(ios, "v1.2.3", "", &telemetry.NoOpService{})() + client, err := plainHttpClientFunc(ios, "v1.2.3", "", &telemetry.NoOpInvocation{})() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index 956df0c78af..229baea00a9 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -429,7 +429,7 @@ func TestNewCmdComment(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) _, err = cmd.ExecuteC() - recorder.Flush() + recorder.Finish() if cmd.Flags().Changed("attach") { values, flagErr := cmd.Flags().GetStringArray("attach") require.NoError(t, flagErr) diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index d131964a9bd..14e6ea5e396 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -331,7 +331,7 @@ func TestNewCmdCreate(t *testing.T) { cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) _, err = cmd.ExecuteC() - recorder.Flush() + recorder.Finish() if cmd.Flags().Changed("attach") { values, flagErr := cmd.Flags().GetStringArray("attach") require.NoError(t, flagErr) @@ -1449,7 +1449,7 @@ func Test_createRun(t *testing.T) { err := createRun(opts) if tt.wantOperations != nil { - attachmentRecorder.Flush() + attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) } if tt.wantsErr == "" { @@ -1493,7 +1493,7 @@ func runCommandWithRootDirOverridden(rt http.RoundTripper, isTTY bool, cli strin Prompter: pm, } - cmd := NewCmdCreate(factory, &telemetry.NoOpService{}, func(opts *CreateOptions) error { + cmd := NewCmdCreate(factory, &telemetry.NoOpInvocation{}, func(opts *CreateOptions) error { opts.RootDirOverride = rootDir opts.Detector = &fd.EnabledDetectorMock{} return createRun(opts) diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 9562aece9f1..77a7870f257 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -476,7 +476,7 @@ func TestNewCmdEdit(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) _, err = cmd.ExecuteC() - recorder.Flush() + recorder.Finish() if cmd.Flags().Changed("attach") { values, flagErr := cmd.Flags().GetStringArray("attach") require.NoError(t, flagErr) @@ -1781,7 +1781,7 @@ func Test_editRun(t *testing.T) { err := editRun(tt.input) if tt.wantOperations != nil { - attachmentRecorder.Flush() + attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) } if tt.wantErr { diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go index bdbdb2c589e..bf166e19609 100644 --- a/pkg/cmd/pr/comment/comment_test.go +++ b/pkg/cmd/pr/comment/comment_test.go @@ -451,7 +451,7 @@ func TestNewCmdComment(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) _, err = cmd.ExecuteC() - recorder.Flush() + recorder.Finish() if cmd.Flags().Changed("attach") { values, flagErr := cmd.Flags().GetStringArray("attach") require.NoError(t, flagErr) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 559a838f333..98a98c4e579 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -354,7 +354,7 @@ func TestNewCmdCreate(t *testing.T) { cmd.SetOut(stderr) cmd.SetErr(stderr) _, err = cmd.ExecuteC() - recorder.Flush() + recorder.Finish() if cmd.Flags().Changed("attach") { values, flagErr := cmd.Flags().GetStringArray("attach") require.NoError(t, flagErr) @@ -2074,7 +2074,7 @@ func Test_createRun(t *testing.T) { err := createRun(&opts) if tt.wantOperations != nil { - attachmentRecorder.Flush() + attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(opts.Assets), *tt.wantOperations) } output := &test.CmdOut{ diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 5184f7069bd..2e900eab435 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -374,7 +374,7 @@ func TestNewCmdEdit(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) _, err = cmd.ExecuteC() - recorder.Flush() + recorder.Finish() if cmd.Flags().Changed("attach") { values, flagErr := cmd.Flags().GetStringArray("attach") require.NoError(t, flagErr) @@ -1717,7 +1717,7 @@ func Test_editRun(t *testing.T) { err := editRun(tt.input) if tt.wantOperations != nil { - attachmentRecorder.Flush() + attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) } if tt.wantErr != "" { diff --git a/pkg/cmd/pr/shared/commentable_test.go b/pkg/cmd/pr/shared/commentable_test.go index 6d04752bc5a..29e93e39b2a 100644 --- a/pkg/cmd/pr/shared/commentable_test.go +++ b/pkg/cmd/pr/shared/commentable_test.go @@ -495,7 +495,7 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { err := CommentableRun(&opts) if tt.wantOperations != nil { - attachmentRecorder.Flush() + attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) } diff --git a/pkg/cmd/root/extension_registration_test.go b/pkg/cmd/root/extension_registration_test.go index f6c624d64bb..1422dd282b2 100644 --- a/pkg/cmd/root/extension_registration_test.go +++ b/pkg/cmd/root/extension_registration_test.go @@ -78,7 +78,7 @@ func TestNewCmdRoot_ExtensionRegistration(t *testing.T) { ExtensionManager: em, } - cmd, err := NewCmdRoot(f, &telemetry.NoOpService{}, "", "") + cmd, err := NewCmdRoot(f, &telemetry.NoOpInvocation{}, "", "") 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 495d2d113d9..201f9ccd80b 100644 --- a/pkg/cmd/root/help_test.go +++ b/pkg/cmd/root/help_test.go @@ -76,7 +76,7 @@ func TestKramdownCompatibleDocs(t *testing.T) { }, } - cmd, err := NewCmdRoot(f, &telemetry.NoOpService{}, "N/A", "") + cmd, err := NewCmdRoot(f, &telemetry.NoOpInvocation{}, "N/A", "") require.NoError(t, err) var walk func(*cobra.Command) diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 34f9188a855..56622ae9919 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -157,7 +157,7 @@ func TestNewCmdInstall(t *testing.T) { } var gotOpts *InstallOptions - cmd := NewCmdInstall(f, &telemetry.NoOpService{}, func(opts *InstallOptions) error { + cmd := NewCmdInstall(f, &telemetry.NoOpInvocation{}, func(opts *InstallOptions) error { gotOpts = opts return nil }) @@ -198,7 +198,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, &telemetry.NoOpService{}, nil) + cmd := NewCmdInstall(f, &telemetry.NoOpInvocation{}, nil) assert.Equal(t, "install [] [flags]", cmd.Use) assert.NotEmpty(t, cmd.Short) @@ -1580,7 +1580,7 @@ func TestInstallRun(t *testing.T) { Agent: "claude-code", Scope: "user", ScopeChanged: true, - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, } }, assert: func(t *testing.T) { @@ -1611,7 +1611,7 @@ func TestInstallRun(t *testing.T) { Agent: "pi", Scope: "user", ScopeChanged: true, - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, } }, assert: func(t *testing.T) { @@ -1644,7 +1644,7 @@ func TestInstallRun(t *testing.T) { ios.SetStderrTTY(tt.isTTY) opts := tt.opts(ios, reg) if opts.Telemetry == nil { - opts.Telemetry = &telemetry.NoOpService{} + opts.Telemetry = &telemetry.NoOpInvocation{} } err := installRun(opts) @@ -1701,7 +1701,7 @@ func TestInstallRun_AllInstallsRemoteSkills(t *testing.T) { Scope: "project", ScopeChanged: true, Dir: targetDir, - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, }) require.NoError(t, err) assert.Contains(t, stdout.String(), "Installed code-review") @@ -1762,7 +1762,7 @@ func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { SkillSource: "monalisa/octocat-skills", SkillName: "git-commit", Force: true, - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, }) require.NoError(t, err) assert.Equal(t, 1, strings.Count(stdout.String(), "Installed git-commit")) @@ -2787,7 +2787,7 @@ func TestInstallRun_UpstreamDetection(t *testing.T) { return 0, nil }, }, - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", @@ -2829,7 +2829,7 @@ func TestInstallRun_UpstreamDetection(t *testing.T) { return 1, nil }, }, - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", @@ -2858,7 +2858,7 @@ func TestInstallRun_UpstreamDetection(t *testing.T) { IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", @@ -2892,7 +2892,7 @@ func TestInstallRun_UpstreamDetection(t *testing.T) { IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go index 94295c7ba6a..fe01f6ae6d3 100644 --- a/pkg/cmd/skills/list/list_test.go +++ b/pkg/cmd/skills/list/list_test.go @@ -88,7 +88,7 @@ func TestNewCmdList(t *testing.T) { } var gotOpts *ListOptions - cmd := NewCmdList(f, &telemetry.NoOpService{}, func(opts *ListOptions) error { + cmd := NewCmdList(f, &telemetry.NoOpInvocation{}, func(opts *ListOptions) error { gotOpts = opts return nil }) @@ -466,6 +466,7 @@ func TestListRun(t *testing.T) { opts := tt.opts(ios, repoDir, homeDir, spy) err := listRun(opts) + spy.Finish() if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 4b74b0622e0..92036841c44 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -84,7 +84,7 @@ func TestNewCmdPreview(t *testing.T) { } var gotOpts *PreviewOptions - cmd := NewCmdPreview(f, &telemetry.NoOpService{}, func(opts *PreviewOptions) error { + cmd := NewCmdPreview(f, &telemetry.NoOpInvocation{}, func(opts *PreviewOptions) error { gotOpts = opts return nil }) @@ -425,7 +425,7 @@ func TestPreviewRun(t *testing.T) { tt.opts.IO = ios tt.opts.Prompter = &prompter.PrompterMock{} - tt.opts.Telemetry = &telemetry.NoOpService{} + tt.opts.Telemetry = &telemetry.NoOpInvocation{} err := previewRun(tt.opts) @@ -448,7 +448,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{}, + Telemetry: &telemetry.NoOpInvocation{}, }) require.ErrorContains(t, err, "does not currently support GitHub Enterprise Server") } @@ -510,7 +510,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{}, + Telemetry: &telemetry.NoOpInvocation{}, } err := previewRun(opts) @@ -606,7 +606,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { Prompter: pm, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, } err := previewRun(opts) @@ -690,7 +690,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { renderCalls++ return fmt.Sprintf("rendered:%s", filePath) }, - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, } err := previewRun(opts) @@ -716,7 +716,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, } err := previewRun(opts) @@ -823,7 +823,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, } err := previewRun(opts) @@ -861,7 +861,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, } err := previewRun(opts) @@ -894,7 +894,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, } err := previewRun(opts) @@ -1253,7 +1253,7 @@ func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, } err := previewRun(opts) @@ -1302,7 +1302,7 @@ func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { repo: ghrepo.New("owner", "repo"), SkillName: "hidden-skill", AllowHiddenDirs: true, - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, } err := previewRun(opts) @@ -1347,7 +1347,7 @@ func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("owner", "repo"), SkillName: "hidden-skill", - Telemetry: &telemetry.NoOpService{}, + Telemetry: &telemetry.NoOpInvocation{}, } err := previewRun(opts) diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index 2b412f037ba..f252ef92d60 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -104,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, &telemetry.NoOpService{}, func(opts *SearchOptions) error { + cmd := NewCmdSearch(f, &telemetry.NoOpInvocation{}, func(opts *SearchOptions) error { gotOpts = opts return nil }) @@ -374,7 +374,7 @@ func TestSearchRun(t *testing.T) { ios.SetStdoutTTY(tt.tty) ios.SetStderrTTY(tt.tty) tt.opts.IO = ios - tt.opts.Telemetry = &telemetry.NoOpService{} + tt.opts.Telemetry = &telemetry.NoOpInvocation{} defer reg.Verify(t) err := searchRun(tt.opts) diff --git a/pkg/cmdutil/telemetry.go b/pkg/cmdutil/telemetry.go index 42169beecec..9bc7782b77d 100644 --- a/pkg/cmdutil/telemetry.go +++ b/pkg/cmdutil/telemetry.go @@ -9,7 +9,8 @@ import ( "github.com/spf13/pflag" ) -func RecordTelemetry(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) { +// RecordTelemetry instruments a command with an invocation-owned telemetry event. +func RecordTelemetry(cmd *cobra.Command, telemetry ghtelemetry.CommandRecorder) { if isTelemetryDisabled(cmd) { return } @@ -18,29 +19,42 @@ func RecordTelemetry(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) { 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{ + var event ghtelemetry.PendingEvent + currentArgs := cmd.Args + cmd.Args = func(cmd *cobra.Command, args []string) error { + event = telemetry.BeginEvent(ghtelemetry.Event{ Type: "command_invocation", - Dimensions: map[string]string{ + Dimensions: ghtelemetry.Dimensions{ "command": cmd.CommandPath(), - "flags": strings.Join(flags, ","), + "flags": telemetryFlags(cmd), }, }) + if currentArgs != nil { + return currentArgs(cmd, args) + } + return nil + } + currentRunE := cmd.RunE + cmd.RunE = func(cmd *cobra.Command, args []string) error { + runErr := currentRunE(cmd, args) + // Commands with DisableFlagParsing may parse their flags inside RunE. + event.SetDimensions(ghtelemetry.Dimensions{"flags": telemetryFlags(cmd)}) return runErr } } -func RecordTelemetryForSubcommands(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) { +func telemetryFlags(cmd *cobra.Command) string { + var flags []string + cmd.Flags().Visit(func(f *pflag.Flag) { + flags = append(flags, f.Name) + }) + slices.Sort(flags) + return strings.Join(flags, ",") +} + +// RecordTelemetryForSubcommands instruments all descendants of a command. +func RecordTelemetryForSubcommands(cmd *cobra.Command, telemetry ghtelemetry.CommandRecorder) { for _, c := range cmd.Commands() { RecordTelemetry(c, telemetry) RecordTelemetryForSubcommands(c, telemetry) diff --git a/pkg/cmdutil/telemetry_test.go b/pkg/cmdutil/telemetry_test.go index bfe4c420ca0..7aecedd5442 100644 --- a/pkg/cmdutil/telemetry_test.go +++ b/pkg/cmdutil/telemetry_test.go @@ -1,9 +1,11 @@ package cmdutil_test import ( + "bytes" "fmt" "testing" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -12,157 +14,252 @@ import ( ) func TestRecordTelemetry(t *testing.T) { - t.Run("records command path and flags", func(t *testing.T) { - recorder := &telemetry.EventRecorderSpy{} + t.Run("records commands that fail argument validation", func(t *testing.T) { + t.Parallel() + + // Given a command that requires a positional argument + recorder := &telemetry.CommandRecorderSpy{} cmd := &cobra.Command{ - Use: "list", - RunE: func(cmd *cobra.Command, args []string) error { return nil }, + Use: "list", + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + 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) - + cmd.SetArgs([]string{"--web"}) 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)) + // When Cobra rejects its arguments and the invocation finishes + _, err := cmd.ExecuteC() + recorder.Finish() + // Then the failure is preserved and the command is still recorded + require.EqualError(t, err, "accepts 1 arg(s), received 0") 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"]) + assert.Equal(t, "command_invocation", recorder.Events[0].Type) + assert.Equal(t, "list", recorder.Events[0].Dimensions["command"]) + assert.Equal(t, "web", recorder.Events[0].Dimensions["flags"]) }) + tests := []struct { + name string + args []string + flags string + }{ + { + name: "records sorted flag names without argument or flag values", + args: []string{"--web", "--repo", "private/repository", "private argument"}, + flags: "repo,web", + }, + { + name: "accepts positional arguments with nil Args and records empty flags", + args: []string{"private argument"}, + flags: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Given a command without argument validation + recorder := &telemetry.CommandRecorderSpy{} + cmd := &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmd.Flags().Bool("web", false, "") + cmd.Flags().String("repo", "", "") + cmd.Flags().Bool("unused", false, "") + parent := &cobra.Command{Use: "pr"} + root := &cobra.Command{Use: "gh"} + root.AddCommand(parent) + parent.AddCommand(cmd) + root.SetArgs(append([]string{"pr", "list"}, tt.args...)) + cmdutil.RecordTelemetry(cmd, recorder) + + // When Cobra executes the command and the invocation finishes + _, err := root.ExecuteC() + recorder.Finish() + + // Then only the command path and explicitly supplied flag names are recorded + require.NoError(t, err) + require.Len(t, recorder.Events, 1) + assert.Equal(t, "command_invocation", recorder.Events[0].Type) + assert.Equal(t, ghtelemetry.Dimensions{ + "command": "gh pr list", + "flags": tt.flags, + }, recorder.Events[0].Dimensions) + }) + } + t.Run("is a no-op when original RunE is nil", func(t *testing.T) { - recorder := &telemetry.EventRecorderSpy{} - cmd := &cobra.Command{Use: "test"} + t.Parallel() + // Given a command using Run instead of RunE + recorder := &telemetry.CommandRecorderSpy{} + output := &bytes.Buffer{} + cmd := &cobra.Command{ + Use: "test", + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintln(cmd.OutOrStdout(), "command output") + }, + } + cmd.SetOut(output) + cmd.SetArgs([]string{}) 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") + // When Cobra executes the command and the invocation finishes + _, err := cmd.ExecuteC() + recorder.Finish() + + // Then its original behavior is retained without telemetry + require.NoError(t, err) + assert.Equal(t, "command output\n", output.String()) + assert.Empty(t, recorder.Events) }) - t.Run("propagates error from original RunE", func(t *testing.T) { - recorder := &telemetry.EventRecorderSpy{} + t.Run("records flags parsed during RunE even when execution fails", func(t *testing.T) { + t.Parallel() + + // Given a command that manually parses flags before execution fails + recorder := &telemetry.CommandRecorderSpy{} expectedErr := fmt.Errorf("something went wrong") cmd := &cobra.Command{ - Use: "fail", - RunE: func(cmd *cobra.Command, args []string) error { return expectedErr }, + Use: "copilot", + DisableFlagParsing: true, + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + cmd.DisableFlagParsing = false + if err := cmd.ParseFlags(args); err != nil { + return err + } + return expectedErr + }, } - + cmd.Flags().Bool("remove", false, "") + cmd.SetArgs([]string{"--remove"}) cmdutil.RecordTelemetry(cmd, recorder) - err := cmd.RunE(cmd, nil) - assert.ErrorIs(t, err, expectedErr) - // Telemetry is still recorded even on error + // When Cobra executes the command and the invocation finishes + _, err := cmd.ExecuteC() + recorder.Finish() + + // Then the error is preserved and the late-parsed flag is recorded + require.ErrorIs(t, err, expectedErr) require.Len(t, recorder.Events, 1) assert.Equal(t, "command_invocation", recorder.Events[0].Type) + assert.Equal(t, "copilot", recorder.Events[0].Dimensions["command"]) + assert.Equal(t, "remove", recorder.Events[0].Dimensions["flags"]) }) - t.Run("flags are sorted alphabetically", func(t *testing.T) { - recorder := &telemetry.EventRecorderSpy{} + t.Run("records commands rejected by a parent persistent pre-run", func(t *testing.T) { + t.Parallel() + + // Given a command whose parent rejects execution before RunE + recorder := &telemetry.CommandRecorderSpy{} + expectedErr := fmt.Errorf("authentication required") + root := &cobra.Command{ + Use: "gh", + SilenceErrors: true, + SilenceUsage: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return expectedErr }, + } cmd := &cobra.Command{ - Use: "test", + Use: "list", 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, "") - + cmd.Flags().Bool("web", false, "") + root.AddCommand(cmd) + root.SetArgs([]string{"list", "--web"}) 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)) + // When Cobra rejects the command and the invocation finishes + _, err := root.ExecuteC() + recorder.Finish() + // Then the parent error is preserved and the attempted command is recorded + require.ErrorIs(t, err, expectedErr) require.Len(t, recorder.Events, 1) - assert.Equal(t, "alpha,middle,zebra", recorder.Events[0].Dimensions["flags"]) + assert.Equal(t, "command_invocation", recorder.Events[0].Type) + assert.Equal(t, "gh list", recorder.Events[0].Dimensions["command"]) + assert.Equal(t, "web", recorder.Events[0].Dimensions["flags"]) }) - t.Run("no flags set records empty flags string", func(t *testing.T) { - recorder := &telemetry.EventRecorderSpy{} + t.Run("records commands rejected by their pre-run", func(t *testing.T) { + t.Parallel() + + // Given a command whose pre-run validation fails + recorder := &telemetry.CommandRecorderSpy{} + expectedErr := fmt.Errorf("incompatible flags") cmd := &cobra.Command{ - Use: "test", - RunE: func(cmd *cobra.Command, args []string) error { return nil }, + Use: "list", + SilenceErrors: true, + SilenceUsage: true, + PreRunE: func(cmd *cobra.Command, args []string) error { return expectedErr }, + RunE: func(cmd *cobra.Command, args []string) error { return nil }, } - cmd.Flags().Bool("unused", false, "") - + cmd.SetArgs([]string{}) cmdutil.RecordTelemetry(cmd, recorder) - require.NoError(t, cmd.RunE(cmd, nil)) + // When Cobra rejects the command and the invocation finishes + _, err := cmd.ExecuteC() + recorder.Finish() + + // Then the validation error is preserved and the attempted command is recorded + require.ErrorIs(t, err, expectedErr) require.Len(t, recorder.Events, 1) - assert.Equal(t, "", recorder.Events[0].Dimensions["flags"]) + assert.Equal(t, "command_invocation", recorder.Events[0].Type) + assert.Equal(t, "list", recorder.Events[0].Dimensions["command"]) }) t.Run("skips commands with telemetry disabled", func(t *testing.T) { - recorder := &telemetry.EventRecorderSpy{} + t.Parallel() + + // Given a command with telemetry disabled + recorder := &telemetry.CommandRecorderSpy{} cmd := &cobra.Command{ Use: "internal", RunE: func(cmd *cobra.Command, args []string) error { return nil }, } + cmd.SetArgs([]string{}) cmdutil.DisableTelemetry(cmd) cmdutil.RecordTelemetry(cmd, recorder) - require.NoError(t, cmd.RunE(cmd, nil)) + // When Cobra executes the command and the invocation finishes + _, err := cmd.ExecuteC() + recorder.Finish() + + // Then the command succeeds without recording telemetry + require.NoError(t, err) 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") - }) + t.Parallel() + + // Given a command tree instrumented from its root + recorder := &telemetry.CommandRecorderSpy{} + 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) + root.SetArgs([]string{"pr", "list"}) + cmdutil.RecordTelemetryForSubcommands(root, recorder) + + // When Cobra executes a nested command and the invocation finishes + _, err := root.ExecuteC() + recorder.Finish() + + // Then only the invoked descendant is recorded + require.NoError(t, err) + 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"]) } From 46e09a5485d04153eccfaac238c389ffaaebf43c Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 8 Sep 2026 16:14:14 +0200 Subject: [PATCH 03/22] separate event recording from invocation policy Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- internal/attachments/flags_test.go | 4 +- internal/attachments/telemetry.go | 4 +- internal/attachments/test.go | 2 +- internal/gh/ghtelemetry/telemetry.go | 14 +++--- internal/telemetry/fake.go | 59 ++++++++++++++------------ internal/telemetry/invocation.go | 5 +++ pkg/cmd/copilot/copilot.go | 2 +- pkg/cmd/copilot/copilot_test.go | 2 +- pkg/cmd/issue/comment/comment.go | 2 +- pkg/cmd/issue/comment/comment_test.go | 2 +- pkg/cmd/issue/create/create.go | 2 +- pkg/cmd/issue/create/create_test.go | 4 +- pkg/cmd/issue/edit/edit.go | 2 +- pkg/cmd/issue/edit/edit_test.go | 4 +- pkg/cmd/issue/issue.go | 2 +- pkg/cmd/pr/comment/comment.go | 2 +- pkg/cmd/pr/comment/comment_test.go | 2 +- pkg/cmd/pr/create/create.go | 2 +- pkg/cmd/pr/create/create_test.go | 4 +- pkg/cmd/pr/edit/edit.go | 2 +- pkg/cmd/pr/edit/edit_test.go | 4 +- pkg/cmd/pr/pr.go | 2 +- pkg/cmd/pr/shared/commentable_test.go | 2 +- pkg/cmd/root/root.go | 2 +- pkg/cmd/skills/install/install.go | 2 +- pkg/cmd/skills/install/install_test.go | 4 +- pkg/cmd/skills/list/list.go | 2 +- pkg/cmd/skills/list/list_test.go | 43 +++++++++---------- pkg/cmd/skills/preview/preview.go | 2 +- pkg/cmd/skills/preview/preview_test.go | 2 +- pkg/cmd/skills/search/search.go | 2 +- pkg/cmd/skills/search/search_test.go | 2 +- pkg/cmd/skills/skills.go | 2 +- pkg/cmd/skills/skills_test.go | 2 +- pkg/cmdutil/telemetry.go | 4 +- pkg/cmdutil/telemetry_test.go | 16 +++---- 36 files changed, 114 insertions(+), 101 deletions(-) diff --git a/internal/attachments/flags_test.go b/internal/attachments/flags_test.go index 34c0d227af9..2cc5bbd0e7d 100644 --- a/internal/attachments/flags_test.go +++ b/internal/attachments/flags_test.go @@ -146,7 +146,7 @@ func TestInvocationTelemetry(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, attachFlag := attachCmd(t, tt.input) - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.InvocationRecorderSpy{} invocationTelemetry := NewInvocationTelemetry(attachFlag, recorder) invocationTelemetry.start("gh issue comment") @@ -189,7 +189,7 @@ func TestInvocationTelemetry(t *testing.T) { } func TestInvocationTelemetryWrapArgsRecordsBeforePersistentPreRunError(t *testing.T) { - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.InvocationRecorderSpy{} cmd := &cobra.Command{ Use: "comment", Args: cobra.ExactArgs(1), diff --git a/internal/attachments/telemetry.go b/internal/attachments/telemetry.go index 1e952fdcece..07940f69e25 100644 --- a/internal/attachments/telemetry.go +++ b/internal/attachments/telemetry.go @@ -9,12 +9,12 @@ import ( // attachments. type InvocationTelemetry struct { flag *Flag - recorder ghtelemetry.CommandRecorder + recorder ghtelemetry.InvocationRecorder event ghtelemetry.PendingEvent } // NewInvocationTelemetry creates attachment telemetry for flag. -func NewInvocationTelemetry(flag *Flag, recorder ghtelemetry.CommandRecorder) *InvocationTelemetry { +func NewInvocationTelemetry(flag *Flag, recorder ghtelemetry.InvocationRecorder) *InvocationTelemetry { return &InvocationTelemetry{ flag: flag, recorder: recorder, diff --git a/internal/attachments/test.go b/internal/attachments/test.go index 00bd63a8f35..8659209e61b 100644 --- a/internal/attachments/test.go +++ b/internal/attachments/test.go @@ -42,7 +42,7 @@ func NewTestAssets(t *testing.T, names ...string) []UserAsset { // NewTestInvocationTelemetry returns telemetry started with the given // attachment count. -func NewTestInvocationTelemetry(t *testing.T, recorder ghtelemetry.CommandRecorder, attachCount int) *InvocationTelemetry { +func NewTestInvocationTelemetry(t *testing.T, recorder ghtelemetry.InvocationRecorder, attachCount int) *InvocationTelemetry { t.Helper() cmd := &cobra.Command{Use: "test"} diff --git a/internal/gh/ghtelemetry/telemetry.go b/internal/gh/ghtelemetry/telemetry.go index 28567417cba..2f1168f2dda 100644 --- a/internal/gh/ghtelemetry/telemetry.go +++ b/internal/gh/ghtelemetry/telemetry.go @@ -21,21 +21,23 @@ type Disabler interface { Disable() } +// EventRecorder produces complete or in-progress events. type EventRecorder interface { Record(event Event) - Disabler + // BeginEvent records initial facts that can be updated until invocation completion. + BeginEvent(Event) PendingEvent } -type CommandRecorder interface { +// InvocationRecorder produces events and controls invocation-wide reporting policy. +type InvocationRecorder interface { EventRecorder - // BeginEvent records initial facts that can be updated until invocation completion. - BeginEvent(Event) PendingEvent + Disabler SetSampleRate(rate int) } -// Invocation collects telemetry throughout command execution and completes it once. +// Invocation owns the lifetime of telemetry collection for a command execution. type Invocation interface { - CommandRecorder + InvocationRecorder Finish() } diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go index b9703147f9c..f15da878858 100644 --- a/internal/telemetry/fake.go +++ b/internal/telemetry/fake.go @@ -6,34 +6,30 @@ import ( "github.com/cli/cli/v2/internal/gh/ghtelemetry" ) +var ( + _ ghtelemetry.EventRecorder = (*EventRecorderSpy)(nil) + _ ghtelemetry.Invocation = (*InvocationRecorderSpy)(nil) +) + +// EventRecorderSpy captures complete events immediately. Finish includes pending +// events in recording order and freezes their handles without requiring policy methods. type EventRecorderSpy struct { - Events []ghtelemetry.Event + Events []ghtelemetry.Event + events []*ghtelemetry.Event + finished bool } +// Record captures a complete event without waiting for Finish. func (r *EventRecorderSpy) Record(event ghtelemetry.Event) { - r.Events = append(r.Events, event) -} - -func (r *EventRecorderSpy) Disable() {} - -// CommandRecorderSpy is a test double for ghtelemetry.CommandRecorder. -// Finish exposes completed events. LastSampleRate captures the sampling policy -// commands attempt to configure. -type CommandRecorderSpy struct { - Events []ghtelemetry.Event - LastSampleRate int - events []*ghtelemetry.Event - finished bool -} - -func (r *CommandRecorderSpy) Record(event ghtelemetry.Event) { + if r.finished { + return + } r.BeginEvent(event) + r.Events = append(r.Events, cloneEvent(event)) } -func (r *CommandRecorderSpy) Disable() {} - // BeginEvent captures initial facts and returns a handle for subsequent updates. -func (r *CommandRecorderSpy) BeginEvent(event ghtelemetry.Event) ghtelemetry.PendingEvent { +func (r *EventRecorderSpy) BeginEvent(event ghtelemetry.Event) ghtelemetry.PendingEvent { if r.finished { return noOpPendingEvent{} } @@ -42,24 +38,35 @@ func (r *CommandRecorderSpy) BeginEvent(event ghtelemetry.Event) ghtelemetry.Pen return &pendingEventSpy{recorder: r, event: &event} } -func (r *CommandRecorderSpy) SetSampleRate(rate int) { - r.LastSampleRate = rate -} - // Finish snapshots recorded facts into Events once. -func (r *CommandRecorderSpy) Finish() { +func (r *EventRecorderSpy) Finish() { if r.finished { return } r.finished = true + r.Events = nil for _, event := range r.events { r.Events = append(r.Events, cloneEvent(*event)) } r.events = nil } +// InvocationRecorderSpy adds invocation policy to EventRecorderSpy. +type InvocationRecorderSpy struct { + EventRecorderSpy + LastSampleRate int +} + +// Disable leaves captured events available for assertions. +func (r *InvocationRecorderSpy) Disable() {} + +// SetSampleRate captures the sampling policy requested by a command. +func (r *InvocationRecorderSpy) SetSampleRate(rate int) { + r.LastSampleRate = rate +} + type pendingEventSpy struct { - recorder *CommandRecorderSpy + recorder *EventRecorderSpy event *ghtelemetry.Event } diff --git a/internal/telemetry/invocation.go b/internal/telemetry/invocation.go index 505de0a2c3d..91cead82bb4 100644 --- a/internal/telemetry/invocation.go +++ b/internal/telemetry/invocation.go @@ -12,6 +12,11 @@ import ( "github.com/google/uuid" ) +var ( + _ ghtelemetry.Invocation = (*Invocation)(nil) + _ ghtelemetry.Invocation = (*NoOpInvocation)(nil) +) + // Invocation owns telemetry facts and reporting policy for one command execution. // Finish must run after command execution and before delivery is flushed. type Invocation struct { diff --git a/pkg/cmd/copilot/copilot.go b/pkg/cmd/copilot/copilot.go index a0a0ce5348b..e3c610de3c8 100644 --- a/pkg/cmd/copilot/copilot.go +++ b/pkg/cmd/copilot/copilot.go @@ -39,7 +39,7 @@ type CopilotOptions struct { Remove bool } -func NewCmdCopilot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*CopilotOptions) error) *cobra.Command { +func NewCmdCopilot(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, runF func(*CopilotOptions) error) *cobra.Command { opts := &CopilotOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, diff --git a/pkg/cmd/copilot/copilot_test.go b/pkg/cmd/copilot/copilot_test.go index 18e3efe78d2..570ab6c45de 100644 --- a/pkg/cmd/copilot/copilot_test.go +++ b/pkg/cmd/copilot/copilot_test.go @@ -115,7 +115,7 @@ func TestNewCmdCopilot(t *testing.T) { assert.NoError(t, err) var gotOpts *CopilotOptions - spy := &telemetry.CommandRecorderSpy{} + spy := &telemetry.InvocationRecorderSpy{} cmd := NewCmdCopilot(f, spy, func(opts *CopilotOptions) error { gotOpts = opts return nil diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index db25c76e7bb..e2f2ecbcfd0 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -12,7 +12,7 @@ import ( "github.com/spf13/cobra" ) -func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*prShared.CommentableOptions) error) *cobra.Command { +func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, runF func(*prShared.CommentableOptions) error) *cobra.Command { opts := &prShared.CommentableOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index 229baea00a9..863e66df480 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -416,7 +416,7 @@ func TestNewCmdComment(t *testing.T) { assert.NoError(t, err) var gotOpts *shared.CommentableOptions - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.InvocationRecorderSpy{} cmd := NewCmdComment(f, recorder, func(opts *shared.CommentableOptions) error { gotOpts = opts return nil diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 3954bef6f2e..dc5b636cdc9 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -63,7 +63,7 @@ type CreateOptions struct { Assets []attachments.UserAsset } -func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*CreateOptions) error) *cobra.Command { +func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, runF func(*CreateOptions) error) *cobra.Command { opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 14e6ea5e396..633d6519185 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -319,7 +319,7 @@ func TestNewCmdCreate(t *testing.T) { } var opts *CreateOptions - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.InvocationRecorderSpy{} cmd := NewCmdCreate(f, recorder, func(o *CreateOptions) error { opts = o return nil @@ -1435,7 +1435,7 @@ func Test_createRun(t *testing.T) { if len(tt.attach) > 0 { opts.Assets = attachments.NewTestAssets(t, tt.attach...) } - attachmentRecorder := &telemetry.CommandRecorderSpy{} + attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { opts.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(tt.attach)) } diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index cc6159a0c35..468a3061c13 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -59,7 +59,7 @@ type EditOptions struct { prShared.Editable } -func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*EditOptions) error) *cobra.Command { +func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, runF func(*EditOptions) error) *cobra.Command { opts := &EditOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 77a7870f257..704114cb989 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -463,7 +463,7 @@ func TestNewCmdEdit(t *testing.T) { assert.NoError(t, err) var gotOpts *EditOptions - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.InvocationRecorderSpy{} cmd := NewCmdEdit(f, recorder, func(opts *EditOptions) error { gotOpts = opts return nil @@ -1766,7 +1766,7 @@ func Test_editRun(t *testing.T) { if len(tt.attach) > 0 { tt.input.Assets = attachments.NewTestAssets(t, tt.attach...) } - attachmentRecorder := &telemetry.CommandRecorderSpy{} + attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { tt.input.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(tt.attach)) } diff --git a/pkg/cmd/issue/issue.go b/pkg/cmd/issue/issue.go index 65f43929cee..4473bfe38d8 100644 --- a/pkg/cmd/issue/issue.go +++ b/pkg/cmd/issue/issue.go @@ -21,7 +21,7 @@ import ( "github.com/spf13/cobra" ) -func NewCmdIssue(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder) *cobra.Command { +func NewCmdIssue(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder) *cobra.Command { cmd := &cobra.Command{ Use: "issue ", Short: "Manage issues", diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go index 0cf48f2b461..2a2e967a10c 100644 --- a/pkg/cmd/pr/comment/comment.go +++ b/pkg/cmd/pr/comment/comment.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*shared.CommentableOptions) error) *cobra.Command { +func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, runF func(*shared.CommentableOptions) error) *cobra.Command { opts := &shared.CommentableOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go index bf166e19609..4e9607d8b78 100644 --- a/pkg/cmd/pr/comment/comment_test.go +++ b/pkg/cmd/pr/comment/comment_test.go @@ -438,7 +438,7 @@ func TestNewCmdComment(t *testing.T) { assert.NoError(t, err) var gotOpts *shared.CommentableOptions - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.InvocationRecorderSpy{} cmd := NewCmdComment(f, recorder, func(opts *shared.CommentableOptions) error { gotOpts = opts return nil diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 46e363adab9..0d3da98a2de 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -197,7 +197,7 @@ type CreateContext struct { GitClient *git.Client } -func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*CreateOptions) error) *cobra.Command { +func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, runF func(*CreateOptions) error) *cobra.Command { opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 98a98c4e579..2343dfe30b2 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -342,7 +342,7 @@ func TestNewCmdCreate(t *testing.T) { } var opts *CreateOptions - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.InvocationRecorderSpy{} cmd := NewCmdCreate(f, recorder, func(o *CreateOptions) error { opts = o return nil @@ -2060,7 +2060,7 @@ func Test_createRun(t *testing.T) { cleanSetup = tt.setup(&opts, t) } defer cleanSetup() - attachmentRecorder := &telemetry.CommandRecorderSpy{} + attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { opts.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(opts.Assets)) } diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index f01544e3f43..202d0e02815 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -48,7 +48,7 @@ type EditOptions struct { shared.Editable } -func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*EditOptions) error) *cobra.Command { +func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, runF func(*EditOptions) error) *cobra.Command { opts := &EditOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 2e900eab435..b67038bf80b 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -361,7 +361,7 @@ func TestNewCmdEdit(t *testing.T) { assert.NoError(t, err) var gotOpts *EditOptions - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.InvocationRecorderSpy{} cmd := NewCmdEdit(f, recorder, func(opts *EditOptions) error { gotOpts = opts return nil @@ -1693,7 +1693,7 @@ func Test_editRun(t *testing.T) { if len(tt.attach) > 0 { tt.input.Assets = attachments.NewTestAssets(t, tt.attach...) } - attachmentRecorder := &telemetry.CommandRecorderSpy{} + attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { tt.input.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(tt.attach)) } diff --git a/pkg/cmd/pr/pr.go b/pkg/cmd/pr/pr.go index 225b62ca7b7..aa40813143a 100644 --- a/pkg/cmd/pr/pr.go +++ b/pkg/cmd/pr/pr.go @@ -24,7 +24,7 @@ import ( "github.com/spf13/cobra" ) -func NewCmdPR(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder) *cobra.Command { +func NewCmdPR(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder) *cobra.Command { cmd := &cobra.Command{ Use: "pr ", Short: "Manage pull requests", diff --git a/pkg/cmd/pr/shared/commentable_test.go b/pkg/cmd/pr/shared/commentable_test.go index 29e93e39b2a..5d231bbfb96 100644 --- a/pkg/cmd/pr/shared/commentable_test.go +++ b/pkg/cmd/pr/shared/commentable_test.go @@ -451,7 +451,7 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { if len(tt.attach) > 0 { opts.Assets = attachments.NewTestAssets(t, tt.attach...) } - attachmentRecorder := &telemetry.CommandRecorderSpy{} + attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { opts.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(tt.attach)) } diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 985a0d82a41..124708cb46a 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -61,7 +61,7 @@ func (ae *AuthError) Error() string { return ae.err.Error() } -func NewCmdRoot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, version, buildDate string) (*cobra.Command, error) { +func NewCmdRoot(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, version, buildDate string) (*cobra.Command, error) { io := f.IOStreams cfg, err := f.Config() if err != nil { diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 64be5da5a79..2095f95a3a5 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -73,7 +73,7 @@ type InstallOptions struct { } // NewCmdInstall creates the "skills install" command. -func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*InstallOptions) error) *cobra.Command { +func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.EventRecorder, runF func(*InstallOptions) error) *cobra.Command { opts := &InstallOptions{ IO: f.IOStreams, Telemetry: telemetry, diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 56622ae9919..0f63e063879 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -157,7 +157,7 @@ func TestNewCmdInstall(t *testing.T) { } var gotOpts *InstallOptions - cmd := NewCmdInstall(f, &telemetry.NoOpInvocation{}, func(opts *InstallOptions) error { + cmd := NewCmdInstall(f, &telemetry.EventRecorderSpy{}, func(opts *InstallOptions) error { gotOpts = opts return nil }) @@ -198,7 +198,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, &telemetry.NoOpInvocation{}, nil) + cmd := NewCmdInstall(f, &telemetry.EventRecorderSpy{}, nil) assert.Equal(t, "install [] [flags]", cmd.Use) assert.NotEmpty(t, cmd.Short) diff --git a/pkg/cmd/skills/list/list.go b/pkg/cmd/skills/list/list.go index 88c6a0d3910..6ec067626d5 100644 --- a/pkg/cmd/skills/list/list.go +++ b/pkg/cmd/skills/list/list.go @@ -106,7 +106,7 @@ func (s listedSkill) ExportData(fields []string) map[string]any { } // NewCmdList creates the "skills list" command. -func NewCmdList(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*ListOptions) error) *cobra.Command { +func NewCmdList(f *cmdutil.Factory, telemetry ghtelemetry.EventRecorder, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ IO: f.IOStreams, Telemetry: telemetry, diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go index fe01f6ae6d3..ad5069e9d32 100644 --- a/pkg/cmd/skills/list/list_test.go +++ b/pkg/cmd/skills/list/list_test.go @@ -88,7 +88,7 @@ func TestNewCmdList(t *testing.T) { } var gotOpts *ListOptions - cmd := NewCmdList(f, &telemetry.NoOpInvocation{}, func(opts *ListOptions) error { + cmd := NewCmdList(f, &telemetry.EventRecorderSpy{}, func(opts *ListOptions) error { gotOpts = opts return nil }) @@ -123,18 +123,18 @@ func TestListRun(t *testing.T) { tests := []struct { name string setup func(t *testing.T, repoDir, homeDir string) - opts func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions + opts func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions wantStdout string wantJSON string wantErr string - verify func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) + verify func(t *testing.T, stdout string, spy *telemetry.EventRecorderSpy) }{ { name: "lists project skill for selected shared agent", setup: func(t *testing.T, repoDir, homeDir string) { writeSkill(t, repoDir, ".agents/skills/git-commit", remoteSkillFrontmatter("git-commit", "skills/git-commit", "refs/tags/v1.0.0", "")) }, - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { return &ListOptions{ IO: ios, Telemetry: spy, @@ -144,7 +144,7 @@ func TestListRun(t *testing.T) { } }, wantStdout: "git-commit\tcursor\tproject\tmonalisa/skills-repo\n", - verify: func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) { + verify: func(t *testing.T, stdout string, spy *telemetry.EventRecorderSpy) { require.Len(t, spy.Events, 1) event := spy.Events[0] assert.Equal(t, "skill_list", event.Type) @@ -158,7 +158,7 @@ func TestListRun(t *testing.T) { setup: func(t *testing.T, repoDir, homeDir string) { writeSkill(t, homeDir, ".copilot/skills/code-review", remoteSkillFrontmatter("code-review", "skills/code-review", "refs/tags/v2.0.0", "v2.0.0")) }, - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { exporter := cmdutil.NewJSONExporter() exporter.SetFields([]string{"skillName", "agentHosts", "scope", "sourceURL", "version", "pinned", "path"}) return &ListOptions{ @@ -181,7 +181,7 @@ func TestListRun(t *testing.T) { "path": %q } ]`, filepath.Join("HOME", ".copilot", "skills", "code-review")), - verify: func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) { + verify: func(t *testing.T, stdout string, spy *telemetry.EventRecorderSpy) { assert.Equal(t, "json", spy.Events[0].Dimensions["format"]) }, }, @@ -190,7 +190,7 @@ func TestListRun(t *testing.T) { setup: func(t *testing.T, repoDir, homeDir string) { writeSkill(t, homeDir, ".copilot/skills/tenant-skill", remoteSkillFrontmatterForRepo("tenant-skill", "https://octocorp.ghe.com/monalisa/skills-repo", "skills/tenant-skill", "refs/heads/main", "")) }, - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { exporter := cmdutil.NewJSONExporter() exporter.SetFields([]string{"skillName", "sourceURL", "path"}) return &ListOptions{ @@ -223,7 +223,7 @@ func TestListRun(t *testing.T) { Body `)) }, - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { return &ListOptions{ IO: ios, Telemetry: spy, @@ -235,7 +235,7 @@ func TestListRun(t *testing.T) { }, { name: "custom directory must exist", - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { return &ListOptions{ IO: ios, Telemetry: spy, @@ -263,7 +263,7 @@ func TestListRun(t *testing.T) { Body `)) }, - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { return &ListOptions{ IO: ios, Telemetry: spy, @@ -278,7 +278,7 @@ func TestListRun(t *testing.T) { setup: func(t *testing.T, repoDir, homeDir string) { writeSkill(t, repoDir, "skills/openclaw-helper", remoteSkillFrontmatter("openclaw-helper", "skills/openclaw-helper", "refs/heads/main", "")) }, - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { return &ListOptions{ IO: ios, Telemetry: spy, @@ -294,7 +294,7 @@ func TestListRun(t *testing.T) { setup: func(t *testing.T, repoDir, homeDir string) { writeSkill(t, repoDir, ".agents/skills/xlsx-pro", remoteSkillFrontmatter("xlsx-pro", "skills/bob/xlsx-pro", "refs/heads/main", "")) }, - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { return &ListOptions{ IO: ios, Telemetry: spy, @@ -310,7 +310,7 @@ func TestListRun(t *testing.T) { setup: func(t *testing.T, repoDir, homeDir string) { writeSkill(t, repoDir, ".agents/skills/foo", remoteSkillFrontmatter("foo", "plugins/myplugin/skills/foo", "refs/heads/main", "")) }, - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { return &ListOptions{ IO: ios, Telemetry: spy, @@ -333,7 +333,7 @@ func TestListRun(t *testing.T) { Body `)) }, - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { exporter := cmdutil.NewJSONExporter() exporter.SetFields([]string{"skillName", "sourceURL", "version", "pinned"}) return &ListOptions{ @@ -356,7 +356,7 @@ func TestListRun(t *testing.T) { }, { name: "no installed skills returns no results", - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { return &ListOptions{ IO: ios, Telemetry: spy, @@ -369,7 +369,7 @@ func TestListRun(t *testing.T) { }, { name: "no installed skills with json returns empty array", - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { exporter := cmdutil.NewJSONExporter() exporter.SetFields([]string{"skillName"}) return &ListOptions{ @@ -393,7 +393,7 @@ func TestListRun(t *testing.T) { require.NoError(t, os.WriteFile(target, []byte("---\nname: linked\nmetadata:\n local-path: /src/linked\n---\nBody\n"), 0o644)) require.NoError(t, os.Symlink(target, filepath.Join(skillDir, "SKILL.md"))) }, - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { return &ListOptions{ IO: ios, Telemetry: spy, @@ -413,7 +413,7 @@ func TestListRun(t *testing.T) { require.NoError(t, os.MkdirAll(targetDir, 0o755)) require.NoError(t, os.Symlink(targetDir, filepath.Join(skillDir, "SKILL.md"))) }, - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { return &ListOptions{ IO: ios, Telemetry: spy, @@ -437,7 +437,7 @@ func TestListRun(t *testing.T) { Body `)) }, - opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.EventRecorderSpy) *ListOptions { return &ListOptions{ IO: ios, Telemetry: spy, @@ -462,11 +462,10 @@ func TestListRun(t *testing.T) { ios, _, stdout, _ := iostreams.Test() ios.SetStdoutTTY(false) - spy := &telemetry.CommandRecorderSpy{} + spy := &telemetry.EventRecorderSpy{} opts := tt.opts(ios, repoDir, homeDir, spy) err := listRun(opts) - spy.Finish() if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 500af05924e..85b88db7564 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -41,7 +41,7 @@ type PreviewOptions struct { } // NewCmdPreview creates the "skills preview" command. -func NewCmdPreview(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*PreviewOptions) error) *cobra.Command { +func NewCmdPreview(f *cmdutil.Factory, telemetry ghtelemetry.EventRecorder, runF func(*PreviewOptions) error) *cobra.Command { opts := &PreviewOptions{ IO: f.IOStreams, Telemetry: telemetry, diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 92036841c44..99d6992b159 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -84,7 +84,7 @@ func TestNewCmdPreview(t *testing.T) { } var gotOpts *PreviewOptions - cmd := NewCmdPreview(f, &telemetry.NoOpInvocation{}, func(opts *PreviewOptions) error { + cmd := NewCmdPreview(f, &telemetry.EventRecorderSpy{}, func(opts *PreviewOptions) error { gotOpts = opts return nil }) diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 30b873a2123..aeb025ec521 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -65,7 +65,7 @@ type SearchOptions struct { } // NewCmdSearch creates the "skills search" command. -func NewCmdSearch(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*SearchOptions) error) *cobra.Command { +func NewCmdSearch(f *cmdutil.Factory, telemetry ghtelemetry.EventRecorder, runF func(*SearchOptions) error) *cobra.Command { opts := &SearchOptions{ IO: f.IOStreams, Telemetry: telemetry, diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index f252ef92d60..c990f82e3a0 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -104,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, &telemetry.NoOpInvocation{}, func(opts *SearchOptions) error { + cmd := NewCmdSearch(f, &telemetry.EventRecorderSpy{}, func(opts *SearchOptions) error { gotOpts = opts return nil }) diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 1399d049b73..0a9d493a97a 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -14,7 +14,7 @@ import ( ) // NewCmdSkills returns the top-level "skill" command. -func NewCmdSkills(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder) *cobra.Command { +func NewCmdSkills(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder) *cobra.Command { cmd := &cobra.Command{ Use: "skill ", Short: "Install and manage agent skills (preview)", diff --git a/pkg/cmd/skills/skills_test.go b/pkg/cmd/skills/skills_test.go index eb8bb465c0e..30f7eb3572b 100644 --- a/pkg/cmd/skills/skills_test.go +++ b/pkg/cmd/skills/skills_test.go @@ -11,7 +11,7 @@ import ( ) func TestSkillCommandsAreSampledAt100(t *testing.T) { - spy := &telemetry.CommandRecorderSpy{} + spy := &telemetry.InvocationRecorderSpy{} factory := &cmdutil.Factory{} cmd := skills.NewCmdSkills(factory, spy) cmd.PersistentPreRunE(nil, []string{}) diff --git a/pkg/cmdutil/telemetry.go b/pkg/cmdutil/telemetry.go index 9bc7782b77d..99d959df53d 100644 --- a/pkg/cmdutil/telemetry.go +++ b/pkg/cmdutil/telemetry.go @@ -10,7 +10,7 @@ import ( ) // RecordTelemetry instruments a command with an invocation-owned telemetry event. -func RecordTelemetry(cmd *cobra.Command, telemetry ghtelemetry.CommandRecorder) { +func RecordTelemetry(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) { if isTelemetryDisabled(cmd) { return } @@ -54,7 +54,7 @@ func telemetryFlags(cmd *cobra.Command) string { } // RecordTelemetryForSubcommands instruments all descendants of a command. -func RecordTelemetryForSubcommands(cmd *cobra.Command, telemetry ghtelemetry.CommandRecorder) { +func RecordTelemetryForSubcommands(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) { for _, c := range cmd.Commands() { RecordTelemetry(c, telemetry) RecordTelemetryForSubcommands(c, telemetry) diff --git a/pkg/cmdutil/telemetry_test.go b/pkg/cmdutil/telemetry_test.go index 7aecedd5442..c1d67d54baa 100644 --- a/pkg/cmdutil/telemetry_test.go +++ b/pkg/cmdutil/telemetry_test.go @@ -18,7 +18,7 @@ func TestRecordTelemetry(t *testing.T) { t.Parallel() // Given a command that requires a positional argument - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.EventRecorderSpy{} cmd := &cobra.Command{ Use: "list", Args: cobra.ExactArgs(1), @@ -63,7 +63,7 @@ func TestRecordTelemetry(t *testing.T) { t.Parallel() // Given a command without argument validation - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.EventRecorderSpy{} cmd := &cobra.Command{ Use: "list", RunE: func(cmd *cobra.Command, args []string) error { return nil }, @@ -97,7 +97,7 @@ func TestRecordTelemetry(t *testing.T) { t.Parallel() // Given a command using Run instead of RunE - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.EventRecorderSpy{} output := &bytes.Buffer{} cmd := &cobra.Command{ Use: "test", @@ -123,7 +123,7 @@ func TestRecordTelemetry(t *testing.T) { t.Parallel() // Given a command that manually parses flags before execution fails - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.EventRecorderSpy{} expectedErr := fmt.Errorf("something went wrong") cmd := &cobra.Command{ Use: "copilot", @@ -158,7 +158,7 @@ func TestRecordTelemetry(t *testing.T) { t.Parallel() // Given a command whose parent rejects execution before RunE - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.EventRecorderSpy{} expectedErr := fmt.Errorf("authentication required") root := &cobra.Command{ Use: "gh", @@ -191,7 +191,7 @@ func TestRecordTelemetry(t *testing.T) { t.Parallel() // Given a command whose pre-run validation fails - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.EventRecorderSpy{} expectedErr := fmt.Errorf("incompatible flags") cmd := &cobra.Command{ Use: "list", @@ -218,7 +218,7 @@ func TestRecordTelemetry(t *testing.T) { t.Parallel() // Given a command with telemetry disabled - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.EventRecorderSpy{} cmd := &cobra.Command{ Use: "internal", RunE: func(cmd *cobra.Command, args []string) error { return nil }, @@ -241,7 +241,7 @@ func TestRecordTelemetryForSubcommands(t *testing.T) { t.Parallel() // Given a command tree instrumented from its root - recorder := &telemetry.CommandRecorderSpy{} + recorder := &telemetry.EventRecorderSpy{} root := &cobra.Command{Use: "gh"} parent := &cobra.Command{Use: "pr"} child := &cobra.Command{ From 7f3b49171f20654b898b390fc53e35d658ef65dc Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 8 Sep 2026 16:36:10 +0200 Subject: [PATCH 04/22] send telemetry when invocation finishes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9452b3e7-02d8-4d4d-b2db-ab18b35657c1 --- internal/ghcmd/cmd.go | 12 +-- internal/telemetry/delivery.go | 34 -------- internal/telemetry/invocation.go | 19 +++-- internal/telemetry/invocation_test.go | 71 +++++++++++++---- internal/telemetry/telemetry.go | 2 +- internal/telemetry/telemetry_test.go | 107 ++++++++------------------ pkg/cmd/api/api_test.go | 4 +- 7 files changed, 105 insertions(+), 144 deletions(-) delete mode 100644 internal/telemetry/delivery.go diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 73823eb2e99..6154b58096f 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -84,7 +84,6 @@ func Main() exitCode { } var invocation ghtelemetry.Invocation - var delivery *telemetry.Delivery switch { case cfgErr != nil: // Without a valid on-disk config we can't honour user telemetry preferences, so disable it to be safe. @@ -102,9 +101,8 @@ func Main() exitCode { // 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. - delivery = telemetry.NewDelivery(telemetry.LogFlusher(ioStreams.ErrOut, ioStreams.ColorEnabled())) invocation = telemetry.NewInvocation( - delivery, + telemetry.LogFlusher(ioStreams.ErrOut, ioStreams.ColorEnabled()), telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), ) if telemetryDisabled { @@ -120,9 +118,8 @@ func Main() exitCode { sampleRate = v } additionalCommonDimensions["sample_rate"] = strconv.Itoa(sampleRate) - delivery = telemetry.NewDelivery(telemetry.GitHubFlusher(ghExecutablePath)) invocation = telemetry.NewInvocation( - delivery, + telemetry.GitHubFlusher(ghExecutablePath), telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), telemetry.WithSampleRate(sampleRate), ) @@ -131,10 +128,7 @@ func Main() exitCode { return exitError } } - if delivery != nil { - defer delivery.Flush() - } - // Complete events before flushing, including returns before Cobra reaches RunE. + // Complete and send events even when returning before Cobra reaches RunE. defer invocation.Finish() cmdFactory := factory.New(buildVersion, string(invokingAgent), cfgFunc, ioStreams, ghExecutablePath, invocation) diff --git a/internal/telemetry/delivery.go b/internal/telemetry/delivery.go deleted file mode 100644 index f96c91785f6..00000000000 --- a/internal/telemetry/delivery.go +++ /dev/null @@ -1,34 +0,0 @@ -package telemetry - -import "sync" - -// Delivery buffers completed invocation payloads until they can be sent. -type Delivery struct { - mu sync.Mutex - send func(SendTelemetryPayload) - payloads []SendTelemetryPayload -} - -// NewDelivery creates a delivery queue using send to transmit each payload. -func NewDelivery(send func(SendTelemetryPayload)) *Delivery { - return &Delivery{send: send} -} - -func (d *Delivery) enqueue(payload SendTelemetryPayload) { - d.mu.Lock() - defer d.mu.Unlock() - - d.payloads = append(d.payloads, payload) -} - -// Flush sends queued payloads. It does not complete in-progress invocations. -func (d *Delivery) Flush() { - d.mu.Lock() - payloads := d.payloads - d.payloads = nil - d.mu.Unlock() - - for _, payload := range payloads { - d.send(payload) - } -} diff --git a/internal/telemetry/invocation.go b/internal/telemetry/invocation.go index 91cead82bb4..66ee709e41d 100644 --- a/internal/telemetry/invocation.go +++ b/internal/telemetry/invocation.go @@ -18,10 +18,10 @@ var ( ) // Invocation owns telemetry facts and reporting policy for one command execution. -// Finish must run after command execution and before delivery is flushed. +// Finish must run after command execution to send the completed payload. type Invocation struct { mu sync.Mutex - delivery *Delivery + send func(SendTelemetryPayload) commonDimensions ghtelemetry.Dimensions sampleRate int sampleBucket byte @@ -62,8 +62,8 @@ func WithSampleRate(rate int) invocationOption { } } -// NewInvocation creates an invocation whose completed payload is queued for delivery. -func NewInvocation(delivery *Delivery, opts ...invocationOption) *Invocation { +// NewInvocation creates an invocation using send to deliver its completed payload. +func NewInvocation(send func(SendTelemetryPayload), opts ...invocationOption) *Invocation { options := invocationOptions{ additionalDimensions: make(ghtelemetry.Dimensions), } @@ -88,7 +88,7 @@ func NewInvocation(delivery *Delivery, opts ...invocationOption) *Invocation { sampleBucket := byte(binary.BigEndian.Uint32(hash[:4]) % 100) return &Invocation{ - delivery: delivery, + send: send, commonDimensions: commonDimensions, sampleRate: options.sampleRate, sampleBucket: sampleBucket, @@ -175,18 +175,19 @@ func (i *Invocation) Disable() { i.disabled = true } -// Finish snapshots the invocation once and queues its payload without sending it. +// Finish snapshots the invocation once and sends its payload after releasing the lock. // Sampling and telemetry eligibility apply to immediate and pending events alike. func (i *Invocation) Finish() { i.mu.Lock() - defer i.mu.Unlock() if i.finished { + i.mu.Unlock() return } i.finished = true if i.sampleRate > 0 && i.sampleRate < 100 && int(i.sampleBucket) >= i.sampleRate { + i.mu.Unlock() return } @@ -209,7 +210,9 @@ func (i *Invocation) Finish() { Measures: maps.Clone(recorded.event.Measures), } } - i.delivery.enqueue(payload) + i.mu.Unlock() + + i.send(payload) } func cloneEvent(event ghtelemetry.Event) ghtelemetry.Event { diff --git a/internal/telemetry/invocation_test.go b/internal/telemetry/invocation_test.go index 06e5d9aff95..742f6bec3d5 100644 --- a/internal/telemetry/invocation_test.go +++ b/internal/telemetry/invocation_test.go @@ -17,8 +17,7 @@ func TestInvocationCopiesFactsAtTheirRecordingTime(t *testing.T) { synctest.Test(t, func(t *testing.T) { // Given a producer that reuses its event and update maps var payload SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { payload = p }) - invocation := NewInvocation(delivery) + invocation := NewInvocation(func(p SendTelemetryPayload) { payload = p }) facts := ghtelemetry.Event{ Type: "command_invocation", Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, @@ -41,7 +40,6 @@ func TestInvocationCopiesFactsAtTheirRecordingTime(t *testing.T) { measures["count"] = 99 time.Sleep(time.Second) invocation.Finish() - delivery.Flush() // Then event order, original timestamps, and independently owned facts survive require.Len(t, payload.Events, 2) @@ -63,8 +61,7 @@ func TestInvocationPromotesAllEventsBeforeCompletion(t *testing.T) { // Given a command that discovers its full-sampling policy after recording facts var payload SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { payload = p }) - invocation := NewInvocation(delivery, WithSampleRate(1)) + invocation := NewInvocation(func(p SendTelemetryPayload) { payload = p }, WithSampleRate(1)) invocation.Record(ghtelemetry.Event{Type: "completed_step"}) pending := invocation.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) @@ -72,7 +69,6 @@ func TestInvocationPromotesAllEventsBeforeCompletion(t *testing.T) { invocation.SetSampleRate(ghtelemetry.SAMPLE_ALL) pending.SetMeasures(ghtelemetry.Measures{"attach_count": 2}) invocation.Finish() - delivery.Flush() // Then immediate and pending events share the promoted sampling policy require.Len(t, payload.Events, 2) @@ -89,8 +85,7 @@ func TestInvocationDisablingOverridesPromotedPendingEvents(t *testing.T) { // Given immediate and pending events in a fully sampled invocation var payloads []SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - invocation := NewInvocation(delivery) + invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) invocation.Record(ghtelemetry.Event{Type: "completed_step"}) pending := invocation.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) invocation.SetSampleRate(ghtelemetry.SAMPLE_ALL) @@ -100,7 +95,6 @@ func TestInvocationDisablingOverridesPromotedPendingEvents(t *testing.T) { pending.SetMeasures(ghtelemetry.Measures{"attach_count": 2}) invocation.Record(ghtelemetry.Event{Type: "another_step"}) invocation.Finish() - delivery.Flush() // Then delivery receives only the empty payload used by log mode require.Len(t, payloads, 1) @@ -112,8 +106,7 @@ func TestInvocationCompletionCannotBeReopened(t *testing.T) { // Given a completed invocation with one pending event var payloads []SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - invocation := NewInvocation(delivery) + invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) pending := invocation.BeginEvent(ghtelemetry.Event{ Type: "command_invocation", Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, @@ -127,9 +120,7 @@ func TestInvocationCompletionCannotBeReopened(t *testing.T) { late.SetDimensions(ghtelemetry.Dimensions{"command": "changed"}) late.SetMeasures(ghtelemetry.Measures{"count": 1}) invocation.Finish() - delivery.Flush() invocation.Finish() - delivery.Flush() // Then the original snapshot is delivered exactly once require.Len(t, payloads, 1) @@ -138,13 +129,62 @@ func TestInvocationCompletionCannotBeReopened(t *testing.T) { assert.Equal(t, "gh issue create", payloads[0].Events[0].Dimensions["command"]) } +func TestInvocationCleanupDuringSendCannotChangePayload(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + // Given a sender that is still working when command cleanup runs + sendStarted := make(chan struct{}) + allowSend := make(chan struct{}) + var payloads []SendTelemetryPayload + invocation := NewInvocation(func(payload SendTelemetryPayload) { + close(sendStarted) + <-allowSend + payloads = append(payloads, payload) + }) + pending := invocation.BeginEvent(ghtelemetry.Event{ + Type: "attachment_invocation", + Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, + Measures: ghtelemetry.Measures{"append_ops_count": 1}, + }) + + // When cleanup updates an old handle and repeats completion during sending + done := make(chan struct{}) + go func() { + invocation.Finish() + close(done) + }() + <-sendStarted + cleanupDone := make(chan struct{}) + go func() { + pending.SetDimensions(ghtelemetry.Dimensions{"command": "changed"}) + pending.SetMeasures(ghtelemetry.Measures{"append_ops_count": 99}) + invocation.Record(ghtelemetry.Event{Type: "too_late"}) + invocation.Finish() + close(cleanupDone) + }() + + // Then cleanup does not block on the sender or change its single snapshot + select { + case <-cleanupDone: + case <-time.After(5 * time.Second): + t.Error("command cleanup blocked while telemetry was being sent") + } + close(allowSend) + <-done + <-cleanupDone + require.Len(t, payloads, 1) + require.Len(t, payloads[0].Events, 1) + assert.Equal(t, "attachment_invocation", payloads[0].Events[0].Type) + assert.Equal(t, "gh issue create", payloads[0].Events[0].Dimensions["command"]) + assert.Equal(t, int64(1), payloads[0].Events[0].Measures["append_ops_count"]) +} + func TestInvocationCollectsConcurrentFacts(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) // Given two command activities contributing to the same pending event var payload SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { payload = p }) - invocation := NewInvocation(delivery) + invocation := NewInvocation(func(p SendTelemetryPayload) { payload = p }) pending := invocation.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) // When both activities finish before command completion @@ -159,7 +199,6 @@ func TestInvocationCollectsConcurrentFacts(t *testing.T) { }) workers.Wait() invocation.Finish() - delivery.Flush() // Then the completed event contains both activities' facts require.Len(t, payload.Events, 1) diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 970bff04a08..5e943113dc5 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -1,5 +1,5 @@ // Package telemetry provides best-effort usage telemetry for gh commands. -// Invocations collect facts until completion; delivery sends completed payloads. +// Invocations collect facts until completion, then send completed payloads. package telemetry import ( diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 118b5672a68..0188d85fe6a 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -299,16 +299,14 @@ func TestNewInvocationLogModeFlushesToWriter(t *testing.T) { // Given an invocation with log delivery t.Cleanup(stubDeviceID("test-device")) var buf bytes.Buffer - delivery := NewDelivery(LogFlusher(&buf, false)) - invocation := NewInvocation(delivery) + invocation := NewInvocation(LogFlusher(&buf, false)) - // When the invocation finishes and delivery is flushed + // When the invocation finishes invocation.Record(ghtelemetry.Event{ Type: "test_event", Dimensions: map[string]string{"key": "value"}, }) invocation.Finish() - delivery.Flush() // Then the writer receives the recorded event output := buf.String() @@ -322,13 +320,11 @@ func TestNewInvocationLogModeWithColorLogsToWriter(t *testing.T) { // Given an invocation with colored log delivery t.Cleanup(stubDeviceID("test-device")) var buf bytes.Buffer - delivery := NewDelivery(LogFlusher(&buf, true)) - invocation := NewInvocation(delivery) + invocation := NewInvocation(LogFlusher(&buf, true)) - // When the invocation finishes and delivery is flushed + // When the invocation finishes invocation.Record(ghtelemetry.Event{Type: "color_event"}) invocation.Finish() - delivery.Flush() // Then the writer receives the event with ANSI color codes output := buf.String() @@ -353,15 +349,14 @@ func TestLogFlusherWritesNoneMarkerForEmptyPayload(t *testing.T) { }) } -func TestInvocationFinishesPendingEventsBeforeDelivery(t *testing.T) { +func TestInvocationFinishSendsPendingEvents(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) // Given an invocation whose attachment operations are not yet known var payloads []SendTelemetryPayload - delivery := NewDelivery(func(payload SendTelemetryPayload) { + invocation := NewInvocation(func(payload SendTelemetryPayload) { payloads = append(payloads, payload) }) - invocation := NewInvocation(delivery) event := invocation.BeginEvent(ghtelemetry.Event{ Type: "attachment_invocation", Measures: ghtelemetry.Measures{ @@ -371,19 +366,16 @@ func TestInvocationFinishesPendingEventsBeforeDelivery(t *testing.T) { }, }) - // When delivery is flushed before the invocation finishes - delivery.Flush() - require.Empty(t, payloads, "delivery must not finalize an unfinished invocation") + require.Empty(t, payloads, "unfinished events must not be sent") + + // When the command supplies its operations and finishes the invocation event.SetMeasures(ghtelemetry.Measures{ "append_ops_count": 1, "replace_ops_count": 1, }) invocation.Finish() - require.Empty(t, payloads, "completion must not send telemetry") - event.SetMeasures(ghtelemetry.Measures{"append_ops_count": 99}) - delivery.Flush() - // Then delivery contains the snapshot taken at invocation completion + // Then Finish sends the completed snapshot without a separate flush require.Len(t, payloads, 1) require.Len(t, payloads[0].Events, 1) assert.Equal(t, "attachment_invocation", payloads[0].Events[0].Type) @@ -398,13 +390,11 @@ func TestInvocationDeviceIDFallback(t *testing.T) { // Given device ID discovery fails t.Cleanup(stubDeviceIDError(errors.New("no device id"))) var captured SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) - invocation := NewInvocation(delivery) + invocation := NewInvocation(func(p SendTelemetryPayload) { captured = p }) // When a recorded event is completed and delivered invocation.Record(ghtelemetry.Event{Type: "test"}) invocation.Finish() - delivery.Flush() // Then the payload identifies the device as unknown require.Len(t, captured.Events, 1) @@ -416,12 +406,10 @@ func TestInvocationFinish(t *testing.T) { // Given an invocation without events and log delivery t.Cleanup(stubDeviceID("test-device")) var buf bytes.Buffer - delivery := NewDelivery(LogFlusher(&buf, false)) - invocation := NewInvocation(delivery) + invocation := NewInvocation(LogFlusher(&buf, false)) - // When the invocation finishes and delivery is flushed + // When the invocation finishes invocation.Finish() - delivery.Flush() // Then log mode explains the absence of telemetry assert.Equal(t, "Telemetry payload: none\n", buf.String()) @@ -431,8 +419,7 @@ func TestInvocationFinish(t *testing.T) { // Given an invocation with common dimensions t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) - invocation := NewInvocation(delivery, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"version": "2.45.0"})) + invocation := NewInvocation(func(p SendTelemetryPayload) { captured = p }, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"version": "2.45.0"})) // When an event with its own dimensions and measures is completed and delivered invocation.Record(ghtelemetry.Event{ @@ -441,7 +428,6 @@ func TestInvocationFinish(t *testing.T) { Measures: map[string]int64{"duration_ms": 150}, }) invocation.Finish() - delivery.Flush() // Then the payload includes both common and event-specific facts require.Len(t, captured.Events, 1) @@ -459,14 +445,12 @@ func TestInvocationFinish(t *testing.T) { // Given an invocation with two recorded events t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) - invocation := NewInvocation(delivery) + invocation := NewInvocation(func(p SendTelemetryPayload) { captured = p }) invocation.Record(ghtelemetry.Event{Type: "event1"}) invocation.Record(ghtelemetry.Event{Type: "event2"}) - // When the invocation finishes and delivery is flushed + // When the invocation finishes invocation.Finish() - delivery.Flush() // Then both events are delivered in recording order require.Len(t, captured.Events, 2) @@ -478,17 +462,13 @@ func TestInvocationFinish(t *testing.T) { // Given an invocation with a recorded event t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - invocation := NewInvocation(delivery) + invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) invocation.Record(ghtelemetry.Event{Type: "test"}) - // When completion and delivery are repeated + // When completion is repeated invocation.Finish() - delivery.Flush() invocation.Finish() - delivery.Flush() invocation.Finish() - delivery.Flush() // Then the recorded event is delivered exactly once require.Len(t, payloads, 1) @@ -500,40 +480,35 @@ func TestInvocationFinish(t *testing.T) { // Given common and event dimensions share a key t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) - invocation := NewInvocation(delivery, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"shared": "common"})) + invocation := NewInvocation(func(p SendTelemetryPayload) { captured = p }, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"shared": "common"})) invocation.Record(ghtelemetry.Event{ Type: "test", Dimensions: map[string]string{"shared": "event-level"}, }) - // When the invocation finishes and delivery is flushed + // When the invocation finishes invocation.Finish() - delivery.Flush() // Then the event dimension takes precedence require.Len(t, captured.Events, 1) assert.Equal(t, "event-level", captured.Events[0].Dimensions["shared"]) }) - t.Run("timestamps reflect record time not completion or delivery time", func(t *testing.T) { + t.Run("timestamps reflect record time not completion time", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) synctest.Test(t, func(t *testing.T) { // Given events recorded at distinct times var captured SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) - invocation := NewInvocation(delivery) + invocation := NewInvocation(func(p SendTelemetryPayload) { captured = p }) firstRecordedAt := time.Now() invocation.Record(ghtelemetry.Event{Type: "early"}) time.Sleep(50 * time.Millisecond) secondRecordedAt := time.Now() invocation.Record(ghtelemetry.Event{Type: "late"}) - // When completion and delivery each happen later + // When completion happens later time.Sleep(time.Second) invocation.Finish() - time.Sleep(time.Second) - delivery.Flush() // Then each timestamp reflects when its event was recorded require.Len(t, captured.Events, 2) @@ -590,15 +565,13 @@ func TestInvocationSampling(t *testing.T) { // Given a configured sample rate and a deterministic sampling bucket t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - invocation := NewInvocation(delivery, WithSampleRate(tt.sampleRate)) + invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }, WithSampleRate(tt.sampleRate)) // Fix the random bucket so sampling boundaries can be asserted through delivery. invocation.sampleBucket = tt.sampleBucket - // When the invocation finishes and delivery is flushed + // When the invocation finishes invocation.Record(ghtelemetry.Event{Type: "test"}) invocation.Finish() - delivery.Flush() // Then only invocations selected by sampling are delivered require.Len(t, payloads, tt.wantPayloads) @@ -615,15 +588,13 @@ func TestInvocationSetSampleRate(t *testing.T) { // Given an invocation that initially sends all events t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - invocation := NewInvocation(delivery, WithSampleRate(0)) + invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }, WithSampleRate(0)) invocation.sampleBucket = 50 // When its sample rate excludes the bucket before completion invocation.SetSampleRate(10) invocation.Record(ghtelemetry.Event{Type: "test"}) invocation.Finish() - delivery.Flush() // Then no payload is delivered assert.Empty(t, payloads) @@ -633,17 +604,15 @@ func TestInvocationSetSampleRate(t *testing.T) { // Given an invocation with an initial sample_rate dimension t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) - invocation := NewInvocation(delivery, + invocation := NewInvocation(func(p SendTelemetryPayload) { captured = p }, WithSampleRate(1), WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"sample_rate": "1"}), ) - // When the rate changes before completion and delivery + // When the rate changes before completion invocation.SetSampleRate(100) invocation.Record(ghtelemetry.Event{Type: "test"}) invocation.Finish() - delivery.Flush() // Then the payload describes the effective sample rate require.Len(t, captured.Events, 1) @@ -655,9 +624,8 @@ func TestWithAdditionalCommonDimensions(t *testing.T) { // Given an invocation constructed with additional common dimensions t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { captured = p }) invocation := NewInvocation( - delivery, + func(p SendTelemetryPayload) { captured = p }, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{ "version": "2.45.0", "agent": "none", @@ -667,7 +635,6 @@ func TestWithAdditionalCommonDimensions(t *testing.T) { // When a recorded event is completed and delivered invocation.Record(ghtelemetry.Event{Type: "test"}) invocation.Finish() - delivery.Flush() // Then both additional and standard dimensions are present require.Len(t, captured.Events, 1) @@ -684,14 +651,12 @@ func TestInvocationDisable(t *testing.T) { // Given an invocation with a recorded event t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - invocation := NewInvocation(delivery) + invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) invocation.Record(ghtelemetry.Event{Type: "test"}) - // When telemetry is disabled before completion and delivery + // When telemetry is disabled before completion invocation.Disable() invocation.Finish() - delivery.Flush() // Then an empty payload is delivered so log mode can surface the absence require.Len(t, payloads, 1) @@ -702,16 +667,14 @@ func TestInvocationDisable(t *testing.T) { // Given an invocation with multiple recorded events t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - invocation := NewInvocation(delivery) + invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) invocation.Record(ghtelemetry.Event{Type: "event1"}) invocation.Record(ghtelemetry.Event{Type: "event2"}) invocation.Record(ghtelemetry.Event{Type: "event3"}) - // When telemetry is disabled before completion and delivery + // When telemetry is disabled before completion invocation.Disable() invocation.Finish() - delivery.Flush() // Then none of the recorded events appear in the delivered payload require.Len(t, payloads, 1) @@ -722,14 +685,12 @@ func TestInvocationDisable(t *testing.T) { // Given an invocation disabled before any events are recorded t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - delivery := NewDelivery(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - invocation := NewInvocation(delivery) + invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) invocation.Disable() // When an event is recorded and the invocation completes invocation.Record(ghtelemetry.Event{Type: "test"}) invocation.Finish() - delivery.Flush() // Then the later event is excluded from the delivered payload require.Len(t, payloads, 1) diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index 9a08e6798cb..8429eddd51a 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -433,10 +433,9 @@ func TestNewCmdApiTelemetry(t *testing.T) { t.Cleanup(server.Close) var payload telemetry.SendTelemetryPayload - delivery := telemetry.NewDelivery(func(p telemetry.SendTelemetryPayload) { + recorder := telemetry.NewInvocation(func(p telemetry.SendTelemetryPayload) { payload = p }) - recorder := telemetry.NewInvocation(delivery) recorder.Record(ghtelemetry.Event{Type: "command"}) ios, _, _, _ := iostreams.Test() @@ -451,7 +450,6 @@ func TestNewCmdApiTelemetry(t *testing.T) { _, err := cmd.ExecuteC() require.NoError(t, err) recorder.Finish() - delivery.Flush() assert.Empty(t, payload.Events) } From c41dd03ff3feb48ec074980259f853bb981550a0 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 8 Sep 2026 17:01:15 +0200 Subject: [PATCH 05/22] restore telemetry service naming Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9452b3e7-02d8-4d4d-b2db-ab18b35657c1 --- cmd/gen-docs/main.go | 2 +- internal/gh/ghtelemetry/telemetry.go | 4 +- internal/ghcmd/cmd.go | 22 +-- internal/telemetry/fake.go | 2 +- .../telemetry/{invocation.go => service.go} | 128 +++++++------- .../{invocation_test.go => service_test.go} | 74 ++++----- internal/telemetry/telemetry_test.go | 156 +++++++++--------- pkg/cmd/api/api_test.go | 2 +- .../verify/verify_integration_test.go | 8 +- pkg/cmd/factory/default_test.go | 4 +- pkg/cmd/issue/create/create_test.go | 2 +- pkg/cmd/root/extension_registration_test.go | 2 +- pkg/cmd/root/help_test.go | 2 +- pkg/cmd/skills/install/install_test.go | 18 +- pkg/cmd/skills/preview/preview_test.go | 24 +-- pkg/cmd/skills/search/search_test.go | 2 +- 16 files changed, 226 insertions(+), 226 deletions(-) rename internal/telemetry/{invocation.go => service.go} (64%) rename internal/telemetry/{invocation_test.go => service_test.go} (75%) diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 2250582ddef..d6a317f595f 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -54,7 +54,7 @@ func run(args []string) error { return config.NewMockConfigFromString(""), nil }, ExtensionManager: &em{}, - }, &telemetry.NoOpInvocation{}, "", "") + }, &telemetry.NoOpService{}, "", "") rootCmd.InitDefaultHelpCmd() if err := os.MkdirAll(*dir, 0755); err != nil { diff --git a/internal/gh/ghtelemetry/telemetry.go b/internal/gh/ghtelemetry/telemetry.go index 2f1168f2dda..83389a4561f 100644 --- a/internal/gh/ghtelemetry/telemetry.go +++ b/internal/gh/ghtelemetry/telemetry.go @@ -35,8 +35,8 @@ type InvocationRecorder interface { SetSampleRate(rate int) } -// Invocation owns the lifetime of telemetry collection for a command execution. -type Invocation interface { +// Service collects telemetry for one command execution and sends it on Finish. +type Service interface { InvocationRecorder Finish() } diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 6154b58096f..eb034389633 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -83,34 +83,34 @@ func Main() exitCode { "spinner_disabled": strconv.FormatBool(ioStreams.GetSpinnerDisabled()), } - var invocation ghtelemetry.Invocation + var telemetryService ghtelemetry.Service switch { case cfgErr != nil: // Without a valid on-disk config we can't honour user telemetry preferences, so disable it to be safe. - invocation = &telemetry.NoOpInvocation{} + telemetryService = &telemetry.NoOpService{} default: telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) telemetryDisabled := mightBeGHESUser(cfg) switch telemetryState { case telemetry.Disabled: - invocation = &telemetry.NoOpInvocation{} + telemetryService = &telemetry.NoOpService{} case telemetry.Logged: - // Always construct the real invocation in log mode so that the log + // 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. - invocation = telemetry.NewInvocation( + telemetryService = telemetry.NewService( telemetry.LogFlusher(ioStreams.ErrOut, ioStreams.ColorEnabled()), telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), ) if telemetryDisabled { - invocation.Disable() + telemetryService.Disable() } case telemetry.Enabled: if telemetryDisabled { - invocation = &telemetry.NoOpInvocation{} + telemetryService = &telemetry.NoOpService{} break } sampleRate := 1 @@ -118,7 +118,7 @@ func Main() exitCode { sampleRate = v } additionalCommonDimensions["sample_rate"] = strconv.Itoa(sampleRate) - invocation = telemetry.NewInvocation( + telemetryService = telemetry.NewService( telemetry.GitHubFlusher(ghExecutablePath), telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), telemetry.WithSampleRate(sampleRate), @@ -129,9 +129,9 @@ func Main() exitCode { } } // Complete and send events even when returning before Cobra reaches RunE. - defer invocation.Finish() + defer telemetryService.Finish() - cmdFactory := factory.New(buildVersion, string(invokingAgent), cfgFunc, ioStreams, ghExecutablePath, invocation) + cmdFactory := factory.New(buildVersion, string(invokingAgent), cfgFunc, ioStreams, ghExecutablePath, telemetryService) if cfgErr == nil { var m migration.MultiAccount @@ -174,7 +174,7 @@ func Main() exitCode { cobra.MousetrapHelpText = "" } - rootCmd, err := root.NewCmdRoot(cmdFactory, invocation, 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/fake.go b/internal/telemetry/fake.go index f15da878858..da7ba9e1e61 100644 --- a/internal/telemetry/fake.go +++ b/internal/telemetry/fake.go @@ -8,7 +8,7 @@ import ( var ( _ ghtelemetry.EventRecorder = (*EventRecorderSpy)(nil) - _ ghtelemetry.Invocation = (*InvocationRecorderSpy)(nil) + _ ghtelemetry.Service = (*InvocationRecorderSpy)(nil) ) // EventRecorderSpy captures complete events immediately. Finish includes pending diff --git a/internal/telemetry/invocation.go b/internal/telemetry/service.go similarity index 64% rename from internal/telemetry/invocation.go rename to internal/telemetry/service.go index 66ee709e41d..721638a7b3f 100644 --- a/internal/telemetry/invocation.go +++ b/internal/telemetry/service.go @@ -13,13 +13,13 @@ import ( ) var ( - _ ghtelemetry.Invocation = (*Invocation)(nil) - _ ghtelemetry.Invocation = (*NoOpInvocation)(nil) + _ ghtelemetry.Service = (*Service)(nil) + _ ghtelemetry.Service = (*NoOpService)(nil) ) -// Invocation owns telemetry facts and reporting policy for one command execution. +// Service records telemetry facts and reporting policy for one command execution. // Finish must run after command execution to send the completed payload. -type Invocation struct { +type Service struct { mu sync.Mutex send func(SendTelemetryPayload) commonDimensions ghtelemetry.Dimensions @@ -36,35 +36,35 @@ type invocationEvent struct { } type pendingEvent struct { - invocation *Invocation - recorded *invocationEvent + service *Service + recorded *invocationEvent } -type invocationOptions struct { +type serviceOptions struct { additionalDimensions ghtelemetry.Dimensions sampleRate int } -type invocationOption func(*invocationOptions) +type serviceOption func(*serviceOptions) // WithAdditionalCommonDimensions sets dimensions shared by every invocation event. -func WithAdditionalCommonDimensions(dimensions ghtelemetry.Dimensions) invocationOption { - return func(options *invocationOptions) { +func WithAdditionalCommonDimensions(dimensions ghtelemetry.Dimensions) serviceOption { + return func(options *serviceOptions) { maps.Copy(options.additionalDimensions, dimensions) } } // WithSampleRate selects invocation-wide sampling. Rates 0 and 100 retain all // events; rates between them select a percentage using the invocation ID. -func WithSampleRate(rate int) invocationOption { - return func(options *invocationOptions) { +func WithSampleRate(rate int) serviceOption { + return func(options *serviceOptions) { options.sampleRate = rate } } -// NewInvocation creates an invocation using send to deliver its completed payload. -func NewInvocation(send func(SendTelemetryPayload), opts ...invocationOption) *Invocation { - options := invocationOptions{ +// NewService creates a telemetry service using send to deliver its completed payload. +func NewService(send func(SendTelemetryPayload), opts ...serviceOption) *Service { + options := serviceOptions{ additionalDimensions: make(ghtelemetry.Dimensions), } for _, opt := range opts { @@ -87,7 +87,7 @@ func NewInvocation(send func(SendTelemetryPayload), opts ...invocationOption) *I hash := uuid.NewSHA1(uuid.Nil, []byte(invocationID)) sampleBucket := byte(binary.BigEndian.Uint32(hash[:4]) % 100) - return &Invocation{ + return &Service{ send: send, commonDimensions: commonDimensions, sampleRate: options.sampleRate, @@ -95,16 +95,16 @@ func NewInvocation(send func(SendTelemetryPayload), opts ...invocationOption) *I } } -// Record copies a complete event into the invocation. +// Record copies a complete event into the service. // Recording after Finish has no effect. -func (i *Invocation) Record(event ghtelemetry.Event) { - i.mu.Lock() - defer i.mu.Unlock() +func (s *Service) Record(event ghtelemetry.Event) { + s.mu.Lock() + defer s.mu.Unlock() - if i.finished { + if s.finished { return } - i.events = append(i.events, &invocationEvent{ + s.events = append(s.events, &invocationEvent{ event: cloneEvent(event), recordedAt: time.Now(), }) @@ -112,26 +112,26 @@ func (i *Invocation) Record(event ghtelemetry.Event) { // BeginEvent copies an event's initial facts and returns a handle for adding // facts until Finish. Events begun after Finish are not recorded. -func (i *Invocation) BeginEvent(event ghtelemetry.Event) ghtelemetry.PendingEvent { - i.mu.Lock() - defer i.mu.Unlock() +func (s *Service) BeginEvent(event ghtelemetry.Event) ghtelemetry.PendingEvent { + s.mu.Lock() + defer s.mu.Unlock() - if i.finished { + if s.finished { return noOpPendingEvent{} } recorded := &invocationEvent{ event: cloneEvent(event), recordedAt: time.Now(), } - i.events = append(i.events, recorded) - return &pendingEvent{invocation: i, recorded: recorded} + s.events = append(s.events, recorded) + return &pendingEvent{service: s, recorded: recorded} } func (p *pendingEvent) SetDimensions(dimensions ghtelemetry.Dimensions) { - p.invocation.mu.Lock() - defer p.invocation.mu.Unlock() + p.service.mu.Lock() + defer p.service.mu.Unlock() - if p.invocation.finished { + if p.service.finished { return } if p.recorded.event.Dimensions == nil { @@ -141,10 +141,10 @@ func (p *pendingEvent) SetDimensions(dimensions ghtelemetry.Dimensions) { } func (p *pendingEvent) SetMeasures(measures ghtelemetry.Measures) { - p.invocation.mu.Lock() - defer p.invocation.mu.Unlock() + p.service.mu.Lock() + defer p.service.mu.Unlock() - if p.invocation.finished { + if p.service.finished { return } if p.recorded.event.Measures == nil { @@ -155,44 +155,44 @@ func (p *pendingEvent) SetMeasures(measures ghtelemetry.Measures) { // SetSampleRate selects the sampling policy for the whole invocation. // Changes after Finish have no effect. -func (i *Invocation) SetSampleRate(rate int) { - i.mu.Lock() - defer i.mu.Unlock() +func (s *Service) SetSampleRate(rate int) { + s.mu.Lock() + defer s.mu.Unlock() - if i.finished { + if s.finished { return } - i.sampleRate = rate - i.commonDimensions["sample_rate"] = strconv.Itoa(rate) + s.sampleRate = rate + s.commonDimensions["sample_rate"] = strconv.Itoa(rate) } // Disable suppresses all events in the invocation, including already recorded // events. It must be called before Finish. -func (i *Invocation) Disable() { - i.mu.Lock() - defer i.mu.Unlock() +func (s *Service) Disable() { + s.mu.Lock() + defer s.mu.Unlock() - i.disabled = true + s.disabled = true } -// Finish snapshots the invocation once and sends its payload after releasing the lock. +// Finish snapshots the recorded events once and sends their payload after releasing the lock. // Sampling and telemetry eligibility apply to immediate and pending events alike. -func (i *Invocation) Finish() { - i.mu.Lock() +func (s *Service) Finish() { + s.mu.Lock() - if i.finished { - i.mu.Unlock() + if s.finished { + s.mu.Unlock() return } - i.finished = true + s.finished = true - if i.sampleRate > 0 && i.sampleRate < 100 && int(i.sampleBucket) >= i.sampleRate { - i.mu.Unlock() + if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate { + s.mu.Unlock() return } - events := i.events - if i.disabled { + events := s.events + if s.disabled { events = nil } @@ -202,7 +202,7 @@ func (i *Invocation) Finish() { dimensions := map[string]string{ "timestamp": recorded.recordedAt.UTC().Format("2006-01-02T15:04:05.000Z"), } - maps.Copy(dimensions, i.commonDimensions) + maps.Copy(dimensions, s.commonDimensions) maps.Copy(dimensions, recorded.event.Dimensions) payload.Events[index] = PayloadEvent{ Type: recorded.event.Type, @@ -210,9 +210,9 @@ func (i *Invocation) Finish() { Measures: maps.Clone(recorded.event.Measures), } } - i.mu.Unlock() + s.mu.Unlock() - i.send(payload) + s.send(payload) } func cloneEvent(event ghtelemetry.Event) ghtelemetry.Event { @@ -228,22 +228,22 @@ type noOpPendingEvent struct{} func (noOpPendingEvent) SetDimensions(ghtelemetry.Dimensions) {} func (noOpPendingEvent) SetMeasures(ghtelemetry.Measures) {} -// NoOpInvocation discards telemetry when collection is disabled. -type NoOpInvocation struct{} +// NoOpService discards telemetry when collection is disabled. +type NoOpService struct{} // Record discards the event. -func (*NoOpInvocation) Record(ghtelemetry.Event) {} +func (*NoOpService) Record(ghtelemetry.Event) {} // BeginEvent returns an inert handle without retaining the event. -func (*NoOpInvocation) BeginEvent(ghtelemetry.Event) ghtelemetry.PendingEvent { +func (*NoOpService) BeginEvent(ghtelemetry.Event) ghtelemetry.PendingEvent { return noOpPendingEvent{} } // Disable leaves telemetry disabled. -func (*NoOpInvocation) Disable() {} +func (*NoOpService) Disable() {} // SetSampleRate leaves telemetry disabled. -func (*NoOpInvocation) SetSampleRate(int) {} +func (*NoOpService) SetSampleRate(int) {} // Finish has no payload to complete. -func (*NoOpInvocation) Finish() {} +func (*NoOpService) Finish() {} diff --git a/internal/telemetry/invocation_test.go b/internal/telemetry/service_test.go similarity index 75% rename from internal/telemetry/invocation_test.go rename to internal/telemetry/service_test.go index 742f6bec3d5..3093160a1a8 100644 --- a/internal/telemetry/invocation_test.go +++ b/internal/telemetry/service_test.go @@ -11,23 +11,23 @@ import ( "github.com/stretchr/testify/require" ) -func TestInvocationCopiesFactsAtTheirRecordingTime(t *testing.T) { +func TestServiceCopiesFactsAtTheirRecordingTime(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) synctest.Test(t, func(t *testing.T) { // Given a producer that reuses its event and update maps var payload SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { payload = p }) + service := NewService(func(p SendTelemetryPayload) { payload = p }) facts := ghtelemetry.Event{ Type: "command_invocation", Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, Measures: ghtelemetry.Measures{"count": 1}, } startedAt := time.Now() - pending := invocation.BeginEvent(facts) + pending := service.BeginEvent(facts) time.Sleep(time.Second) facts.Type = "completed_step" - invocation.Record(facts) + service.Record(facts) // When the producer updates pending facts and later reuses those maps facts.Dimensions["command"] = "unrelated" @@ -39,7 +39,7 @@ func TestInvocationCopiesFactsAtTheirRecordingTime(t *testing.T) { dimensions["flags"] = "unrelated" measures["count"] = 99 time.Sleep(time.Second) - invocation.Finish() + service.Finish() // Then event order, original timestamps, and independently owned facts survive require.Len(t, payload.Events, 2) @@ -56,19 +56,19 @@ func TestInvocationCopiesFactsAtTheirRecordingTime(t *testing.T) { }) } -func TestInvocationPromotesAllEventsBeforeCompletion(t *testing.T) { +func TestServicePromotesAllEventsBeforeCompletion(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) // Given a command that discovers its full-sampling policy after recording facts var payload SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { payload = p }, WithSampleRate(1)) - invocation.Record(ghtelemetry.Event{Type: "completed_step"}) - pending := invocation.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) + service := NewService(func(p SendTelemetryPayload) { payload = p }, WithSampleRate(1)) + service.Record(ghtelemetry.Event{Type: "completed_step"}) + pending := service.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) // When attachment usage promotes the invocation before it finishes - invocation.SetSampleRate(ghtelemetry.SAMPLE_ALL) + service.SetSampleRate(ghtelemetry.SAMPLE_ALL) pending.SetMeasures(ghtelemetry.Measures{"attach_count": 2}) - invocation.Finish() + service.Finish() // Then immediate and pending events share the promoted sampling policy require.Len(t, payload.Events, 2) @@ -80,47 +80,47 @@ func TestInvocationPromotesAllEventsBeforeCompletion(t *testing.T) { assert.Equal(t, int64(2), payload.Events[1].Measures["attach_count"]) } -func TestInvocationDisablingOverridesPromotedPendingEvents(t *testing.T) { +func TestServiceDisablingOverridesPromotedPendingEvents(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) // Given immediate and pending events in a fully sampled invocation var payloads []SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - invocation.Record(ghtelemetry.Event{Type: "completed_step"}) - pending := invocation.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) - invocation.SetSampleRate(ghtelemetry.SAMPLE_ALL) + service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + service.Record(ghtelemetry.Event{Type: "completed_step"}) + pending := service.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) + service.SetSampleRate(ghtelemetry.SAMPLE_ALL) // When host discovery disables telemetry before completion - invocation.Disable() + service.Disable() pending.SetMeasures(ghtelemetry.Measures{"attach_count": 2}) - invocation.Record(ghtelemetry.Event{Type: "another_step"}) - invocation.Finish() + service.Record(ghtelemetry.Event{Type: "another_step"}) + service.Finish() // Then delivery receives only the empty payload used by log mode require.Len(t, payloads, 1) assert.Empty(t, payloads[0].Events) } -func TestInvocationCompletionCannotBeReopened(t *testing.T) { +func TestServiceCompletionCannotBeReopened(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) // Given a completed invocation with one pending event var payloads []SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - pending := invocation.BeginEvent(ghtelemetry.Event{ + service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + pending := service.BeginEvent(ghtelemetry.Event{ Type: "command_invocation", Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, }) - invocation.Finish() + service.Finish() // When cleanup repeats or code holding an old handle attempts further recording pending.SetDimensions(ghtelemetry.Dimensions{"command": "changed"}) - invocation.Record(ghtelemetry.Event{Type: "too_late"}) - late := invocation.BeginEvent(ghtelemetry.Event{Type: "also_too_late"}) + service.Record(ghtelemetry.Event{Type: "too_late"}) + late := service.BeginEvent(ghtelemetry.Event{Type: "also_too_late"}) late.SetDimensions(ghtelemetry.Dimensions{"command": "changed"}) late.SetMeasures(ghtelemetry.Measures{"count": 1}) - invocation.Finish() - invocation.Finish() + service.Finish() + service.Finish() // Then the original snapshot is delivered exactly once require.Len(t, payloads, 1) @@ -129,19 +129,19 @@ func TestInvocationCompletionCannotBeReopened(t *testing.T) { assert.Equal(t, "gh issue create", payloads[0].Events[0].Dimensions["command"]) } -func TestInvocationCleanupDuringSendCannotChangePayload(t *testing.T) { +func TestServiceCleanupDuringSendCannotChangePayload(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) // Given a sender that is still working when command cleanup runs sendStarted := make(chan struct{}) allowSend := make(chan struct{}) var payloads []SendTelemetryPayload - invocation := NewInvocation(func(payload SendTelemetryPayload) { + service := NewService(func(payload SendTelemetryPayload) { close(sendStarted) <-allowSend payloads = append(payloads, payload) }) - pending := invocation.BeginEvent(ghtelemetry.Event{ + pending := service.BeginEvent(ghtelemetry.Event{ Type: "attachment_invocation", Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, Measures: ghtelemetry.Measures{"append_ops_count": 1}, @@ -150,7 +150,7 @@ func TestInvocationCleanupDuringSendCannotChangePayload(t *testing.T) { // When cleanup updates an old handle and repeats completion during sending done := make(chan struct{}) go func() { - invocation.Finish() + service.Finish() close(done) }() <-sendStarted @@ -158,8 +158,8 @@ func TestInvocationCleanupDuringSendCannotChangePayload(t *testing.T) { go func() { pending.SetDimensions(ghtelemetry.Dimensions{"command": "changed"}) pending.SetMeasures(ghtelemetry.Measures{"append_ops_count": 99}) - invocation.Record(ghtelemetry.Event{Type: "too_late"}) - invocation.Finish() + service.Record(ghtelemetry.Event{Type: "too_late"}) + service.Finish() close(cleanupDone) }() @@ -179,13 +179,13 @@ func TestInvocationCleanupDuringSendCannotChangePayload(t *testing.T) { assert.Equal(t, int64(1), payloads[0].Events[0].Measures["append_ops_count"]) } -func TestInvocationCollectsConcurrentFacts(t *testing.T) { +func TestServiceCollectsConcurrentFacts(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) // Given two command activities contributing to the same pending event var payload SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { payload = p }) - pending := invocation.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) + service := NewService(func(p SendTelemetryPayload) { payload = p }) + pending := service.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) // When both activities finish before command completion var workers sync.WaitGroup @@ -198,7 +198,7 @@ func TestInvocationCollectsConcurrentFacts(t *testing.T) { pending.SetMeasures(ghtelemetry.Measures{"replace_ops_count": 2}) }) workers.Wait() - invocation.Finish() + service.Finish() // Then the completed event contains both activities' facts require.Len(t, payload.Events, 1) diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 0188d85fe6a..4be380101bf 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -295,18 +295,18 @@ func TestParseTelemetryState(t *testing.T) { } } -func TestNewInvocationLogModeFlushesToWriter(t *testing.T) { +func TestNewServiceLogModeFlushesToWriter(t *testing.T) { // Given an invocation with log delivery t.Cleanup(stubDeviceID("test-device")) var buf bytes.Buffer - invocation := NewInvocation(LogFlusher(&buf, false)) + service := NewService(LogFlusher(&buf, false)) // When the invocation finishes - invocation.Record(ghtelemetry.Event{ + service.Record(ghtelemetry.Event{ Type: "test_event", Dimensions: map[string]string{"key": "value"}, }) - invocation.Finish() + service.Finish() // Then the writer receives the recorded event output := buf.String() @@ -316,15 +316,15 @@ func TestNewInvocationLogModeFlushesToWriter(t *testing.T) { assert.Contains(t, output, `"value"`) } -func TestNewInvocationLogModeWithColorLogsToWriter(t *testing.T) { +func TestNewServiceLogModeWithColorLogsToWriter(t *testing.T) { // Given an invocation with colored log delivery t.Cleanup(stubDeviceID("test-device")) var buf bytes.Buffer - invocation := NewInvocation(LogFlusher(&buf, true)) + service := NewService(LogFlusher(&buf, true)) // When the invocation finishes - invocation.Record(ghtelemetry.Event{Type: "color_event"}) - invocation.Finish() + service.Record(ghtelemetry.Event{Type: "color_event"}) + service.Finish() // Then the writer receives the event with ANSI color codes output := buf.String() @@ -349,15 +349,15 @@ func TestLogFlusherWritesNoneMarkerForEmptyPayload(t *testing.T) { }) } -func TestInvocationFinishSendsPendingEvents(t *testing.T) { +func TestServiceFinishSendsPendingEvents(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) // Given an invocation whose attachment operations are not yet known var payloads []SendTelemetryPayload - invocation := NewInvocation(func(payload SendTelemetryPayload) { + service := NewService(func(payload SendTelemetryPayload) { payloads = append(payloads, payload) }) - event := invocation.BeginEvent(ghtelemetry.Event{ + event := service.BeginEvent(ghtelemetry.Event{ Type: "attachment_invocation", Measures: ghtelemetry.Measures{ "attach_count": 2, @@ -373,7 +373,7 @@ func TestInvocationFinishSendsPendingEvents(t *testing.T) { "append_ops_count": 1, "replace_ops_count": 1, }) - invocation.Finish() + service.Finish() // Then Finish sends the completed snapshot without a separate flush require.Len(t, payloads, 1) @@ -386,30 +386,30 @@ func TestInvocationFinishSendsPendingEvents(t *testing.T) { }, payloads[0].Events[0].Measures) } -func TestInvocationDeviceIDFallback(t *testing.T) { +func TestServiceDeviceIDFallback(t *testing.T) { // Given device ID discovery fails t.Cleanup(stubDeviceIDError(errors.New("no device id"))) var captured SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { captured = p }) + service := NewService(func(p SendTelemetryPayload) { captured = p }) // When a recorded event is completed and delivered - invocation.Record(ghtelemetry.Event{Type: "test"}) - invocation.Finish() + service.Record(ghtelemetry.Event{Type: "test"}) + service.Finish() // Then the payload identifies the device as unknown require.Len(t, captured.Events, 1) assert.Equal(t, "", captured.Events[0].Dimensions["device_id"]) } -func TestInvocationFinish(t *testing.T) { +func TestServiceFinish(t *testing.T) { t.Run("logs none when no events recorded", func(t *testing.T) { // Given an invocation without events and log delivery t.Cleanup(stubDeviceID("test-device")) var buf bytes.Buffer - invocation := NewInvocation(LogFlusher(&buf, false)) + service := NewService(LogFlusher(&buf, false)) // When the invocation finishes - invocation.Finish() + service.Finish() // Then log mode explains the absence of telemetry assert.Equal(t, "Telemetry payload: none\n", buf.String()) @@ -419,15 +419,15 @@ func TestInvocationFinish(t *testing.T) { // Given an invocation with common dimensions t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { captured = p }, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"version": "2.45.0"})) + service := NewService(func(p SendTelemetryPayload) { captured = p }, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"version": "2.45.0"})) // When an event with its own dimensions and measures is completed and delivered - invocation.Record(ghtelemetry.Event{ + service.Record(ghtelemetry.Event{ Type: "command_invocation", Dimensions: map[string]string{"command": "gh pr list"}, Measures: map[string]int64{"duration_ms": 150}, }) - invocation.Finish() + service.Finish() // Then the payload includes both common and event-specific facts require.Len(t, captured.Events, 1) @@ -445,12 +445,12 @@ func TestInvocationFinish(t *testing.T) { // Given an invocation with two recorded events t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { captured = p }) - invocation.Record(ghtelemetry.Event{Type: "event1"}) - invocation.Record(ghtelemetry.Event{Type: "event2"}) + service := NewService(func(p SendTelemetryPayload) { captured = p }) + service.Record(ghtelemetry.Event{Type: "event1"}) + service.Record(ghtelemetry.Event{Type: "event2"}) // When the invocation finishes - invocation.Finish() + service.Finish() // Then both events are delivered in recording order require.Len(t, captured.Events, 2) @@ -462,13 +462,13 @@ func TestInvocationFinish(t *testing.T) { // Given an invocation with a recorded event t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - invocation.Record(ghtelemetry.Event{Type: "test"}) + service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + service.Record(ghtelemetry.Event{Type: "test"}) // When completion is repeated - invocation.Finish() - invocation.Finish() - invocation.Finish() + service.Finish() + service.Finish() + service.Finish() // Then the recorded event is delivered exactly once require.Len(t, payloads, 1) @@ -480,14 +480,14 @@ func TestInvocationFinish(t *testing.T) { // Given common and event dimensions share a key t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { captured = p }, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"shared": "common"})) - invocation.Record(ghtelemetry.Event{ + service := NewService(func(p SendTelemetryPayload) { captured = p }, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"shared": "common"})) + service.Record(ghtelemetry.Event{ Type: "test", Dimensions: map[string]string{"shared": "event-level"}, }) // When the invocation finishes - invocation.Finish() + service.Finish() // Then the event dimension takes precedence require.Len(t, captured.Events, 1) @@ -499,16 +499,16 @@ func TestInvocationFinish(t *testing.T) { synctest.Test(t, func(t *testing.T) { // Given events recorded at distinct times var captured SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { captured = p }) + service := NewService(func(p SendTelemetryPayload) { captured = p }) firstRecordedAt := time.Now() - invocation.Record(ghtelemetry.Event{Type: "early"}) + service.Record(ghtelemetry.Event{Type: "early"}) time.Sleep(50 * time.Millisecond) secondRecordedAt := time.Now() - invocation.Record(ghtelemetry.Event{Type: "late"}) + service.Record(ghtelemetry.Event{Type: "late"}) // When completion happens later time.Sleep(time.Second) - invocation.Finish() + service.Finish() // Then each timestamp reflects when its event was recorded require.Len(t, captured.Events, 2) @@ -522,7 +522,7 @@ func TestInvocationFinish(t *testing.T) { }) } -func TestInvocationSampling(t *testing.T) { +func TestServiceSampling(t *testing.T) { tests := []struct { name string sampleRate int @@ -565,13 +565,13 @@ func TestInvocationSampling(t *testing.T) { // Given a configured sample rate and a deterministic sampling bucket t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }, WithSampleRate(tt.sampleRate)) + service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }, WithSampleRate(tt.sampleRate)) // Fix the random bucket so sampling boundaries can be asserted through delivery. - invocation.sampleBucket = tt.sampleBucket + service.sampleBucket = tt.sampleBucket // When the invocation finishes - invocation.Record(ghtelemetry.Event{Type: "test"}) - invocation.Finish() + service.Record(ghtelemetry.Event{Type: "test"}) + service.Finish() // Then only invocations selected by sampling are delivered require.Len(t, payloads, tt.wantPayloads) @@ -583,18 +583,18 @@ func TestInvocationSampling(t *testing.T) { } } -func TestInvocationSetSampleRate(t *testing.T) { +func TestServiceSetSampleRate(t *testing.T) { t.Run("changes delivery eligibility", func(t *testing.T) { // Given an invocation that initially sends all events t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }, WithSampleRate(0)) - invocation.sampleBucket = 50 + service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }, WithSampleRate(0)) + service.sampleBucket = 50 // When its sample rate excludes the bucket before completion - invocation.SetSampleRate(10) - invocation.Record(ghtelemetry.Event{Type: "test"}) - invocation.Finish() + service.SetSampleRate(10) + service.Record(ghtelemetry.Event{Type: "test"}) + service.Finish() // Then no payload is delivered assert.Empty(t, payloads) @@ -604,15 +604,15 @@ func TestInvocationSetSampleRate(t *testing.T) { // Given an invocation with an initial sample_rate dimension t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { captured = p }, + service := NewService(func(p SendTelemetryPayload) { captured = p }, WithSampleRate(1), WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"sample_rate": "1"}), ) // When the rate changes before completion - invocation.SetSampleRate(100) - invocation.Record(ghtelemetry.Event{Type: "test"}) - invocation.Finish() + service.SetSampleRate(100) + service.Record(ghtelemetry.Event{Type: "test"}) + service.Finish() // Then the payload describes the effective sample rate require.Len(t, captured.Events, 1) @@ -624,7 +624,7 @@ func TestWithAdditionalCommonDimensions(t *testing.T) { // Given an invocation constructed with additional common dimensions t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload - invocation := NewInvocation( + service := NewService( func(p SendTelemetryPayload) { captured = p }, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{ "version": "2.45.0", @@ -633,8 +633,8 @@ func TestWithAdditionalCommonDimensions(t *testing.T) { ) // When a recorded event is completed and delivered - invocation.Record(ghtelemetry.Event{Type: "test"}) - invocation.Finish() + service.Record(ghtelemetry.Event{Type: "test"}) + service.Finish() // Then both additional and standard dimensions are present require.Len(t, captured.Events, 1) @@ -646,17 +646,17 @@ func TestWithAdditionalCommonDimensions(t *testing.T) { assert.NotEmpty(t, captured.Events[0].Dimensions["architecture"]) } -func TestInvocationDisable(t *testing.T) { +func TestServiceDisable(t *testing.T) { t.Run("drops recorded events from delivered payload", func(t *testing.T) { // Given an invocation with a recorded event t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - invocation.Record(ghtelemetry.Event{Type: "test"}) + service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + service.Record(ghtelemetry.Event{Type: "test"}) // When telemetry is disabled before completion - invocation.Disable() - invocation.Finish() + service.Disable() + service.Finish() // Then an empty payload is delivered so log mode can surface the absence require.Len(t, payloads, 1) @@ -667,14 +667,14 @@ func TestInvocationDisable(t *testing.T) { // Given an invocation with multiple recorded events t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - invocation.Record(ghtelemetry.Event{Type: "event1"}) - invocation.Record(ghtelemetry.Event{Type: "event2"}) - invocation.Record(ghtelemetry.Event{Type: "event3"}) + service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + service.Record(ghtelemetry.Event{Type: "event1"}) + service.Record(ghtelemetry.Event{Type: "event2"}) + service.Record(ghtelemetry.Event{Type: "event3"}) // When telemetry is disabled before completion - invocation.Disable() - invocation.Finish() + service.Disable() + service.Finish() // Then none of the recorded events appear in the delivered payload require.Len(t, payloads, 1) @@ -685,12 +685,12 @@ func TestInvocationDisable(t *testing.T) { // Given an invocation disabled before any events are recorded t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - invocation := NewInvocation(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - invocation.Disable() + service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + service.Disable() // When an event is recorded and the invocation completes - invocation.Record(ghtelemetry.Event{Type: "test"}) - invocation.Finish() + service.Record(ghtelemetry.Event{Type: "test"}) + service.Finish() // Then the later event is excluded from the delivered payload require.Len(t, payloads, 1) @@ -698,16 +698,16 @@ func TestInvocationDisable(t *testing.T) { }) } -func TestNoOpInvocation(t *testing.T) { - invocation := &NoOpInvocation{} +func TestNoOpService(t *testing.T) { + service := &NoOpService{} // All methods should be safe to call without panicking - invocation.Record(ghtelemetry.Event{Type: "test"}) - event := invocation.BeginEvent(ghtelemetry.Event{Type: "pending"}) + service.Record(ghtelemetry.Event{Type: "test"}) + event := service.BeginEvent(ghtelemetry.Event{Type: "pending"}) event.SetDimensions(ghtelemetry.Dimensions{"key": "value"}) event.SetMeasures(ghtelemetry.Measures{"count": 1}) - invocation.Disable() - invocation.SetSampleRate(50) - invocation.Finish() + service.Disable() + service.SetSampleRate(50) + service.Finish() } func TestSpawnSendTelemetryRejectsOversizedPayload(t *testing.T) { diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index 8429eddd51a..9075577ab66 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -433,7 +433,7 @@ func TestNewCmdApiTelemetry(t *testing.T) { t.Cleanup(server.Close) var payload telemetry.SendTelemetryPayload - recorder := telemetry.NewInvocation(func(p telemetry.SendTelemetryPayload) { + recorder := telemetry.NewService(func(p telemetry.SendTelemetryPayload) { payload = p }) recorder.Record(ghtelemetry.Event{Type: "command"}) diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index f2b51d5fec0..137880e6f63 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -36,7 +36,7 @@ func TestVerifyIntegration(t *testing.T) { ios, "test", "", - &telemetry.NoOpInvocation{}, + &telemetry.NoOpService{}, )() require.NoError(t, err) @@ -156,7 +156,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { ios, "test", "", - &telemetry.NoOpInvocation{}, + &telemetry.NoOpService{}, )() require.NoError(t, err) @@ -234,7 +234,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { ios, "test", "", - &telemetry.NoOpInvocation{}, + &telemetry.NoOpService{}, )() require.NoError(t, err) @@ -331,7 +331,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { ios, "test", "", - &telemetry.NoOpInvocation{}, + &telemetry.NoOpService{}, )() require.NoError(t, err) diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index 43df914c2ca..c41b77506d4 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.NewMockConfig() ios, _, _, stderr := iostreams.Test() - client, err := HttpClientFunc(func() (gh.Config, error) { return cfg, nil }, ios, "v1.2.3", "", &telemetry.NoOpInvocation{})() + 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 != "" { @@ -383,7 +383,7 @@ func TestPlainHttpClient(t *testing.T) { defer ts.Close() ios, _, _, _ := iostreams.Test() - client, err := plainHttpClientFunc(ios, "v1.2.3", "", &telemetry.NoOpInvocation{})() + client, err := plainHttpClientFunc(ios, "v1.2.3", "", &telemetry.NoOpService{})() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 633d6519185..c7fd2c01667 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -1493,7 +1493,7 @@ func runCommandWithRootDirOverridden(rt http.RoundTripper, isTTY bool, cli strin Prompter: pm, } - cmd := NewCmdCreate(factory, &telemetry.NoOpInvocation{}, func(opts *CreateOptions) error { + cmd := NewCmdCreate(factory, &telemetry.NoOpService{}, func(opts *CreateOptions) error { opts.RootDirOverride = rootDir opts.Detector = &fd.EnabledDetectorMock{} return createRun(opts) diff --git a/pkg/cmd/root/extension_registration_test.go b/pkg/cmd/root/extension_registration_test.go index 1422dd282b2..f6c624d64bb 100644 --- a/pkg/cmd/root/extension_registration_test.go +++ b/pkg/cmd/root/extension_registration_test.go @@ -78,7 +78,7 @@ func TestNewCmdRoot_ExtensionRegistration(t *testing.T) { ExtensionManager: em, } - cmd, err := NewCmdRoot(f, &telemetry.NoOpInvocation{}, "", "") + 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 201f9ccd80b..495d2d113d9 100644 --- a/pkg/cmd/root/help_test.go +++ b/pkg/cmd/root/help_test.go @@ -76,7 +76,7 @@ func TestKramdownCompatibleDocs(t *testing.T) { }, } - cmd, err := NewCmdRoot(f, &telemetry.NoOpInvocation{}, "N/A", "") + cmd, err := NewCmdRoot(f, &telemetry.NoOpService{}, "N/A", "") require.NoError(t, err) var walk func(*cobra.Command) diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 0f63e063879..e04b74aa197 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -1580,7 +1580,7 @@ func TestInstallRun(t *testing.T) { Agent: "claude-code", Scope: "user", ScopeChanged: true, - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, } }, assert: func(t *testing.T) { @@ -1611,7 +1611,7 @@ func TestInstallRun(t *testing.T) { Agent: "pi", Scope: "user", ScopeChanged: true, - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, } }, assert: func(t *testing.T) { @@ -1644,7 +1644,7 @@ func TestInstallRun(t *testing.T) { ios.SetStderrTTY(tt.isTTY) opts := tt.opts(ios, reg) if opts.Telemetry == nil { - opts.Telemetry = &telemetry.NoOpInvocation{} + opts.Telemetry = &telemetry.NoOpService{} } err := installRun(opts) @@ -1701,7 +1701,7 @@ func TestInstallRun_AllInstallsRemoteSkills(t *testing.T) { Scope: "project", ScopeChanged: true, Dir: targetDir, - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, }) require.NoError(t, err) assert.Contains(t, stdout.String(), "Installed code-review") @@ -1762,7 +1762,7 @@ func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { SkillSource: "monalisa/octocat-skills", SkillName: "git-commit", Force: true, - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, }) require.NoError(t, err) assert.Equal(t, 1, strings.Count(stdout.String(), "Installed git-commit")) @@ -2787,7 +2787,7 @@ func TestInstallRun_UpstreamDetection(t *testing.T) { return 0, nil }, }, - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", @@ -2829,7 +2829,7 @@ func TestInstallRun_UpstreamDetection(t *testing.T) { return 1, nil }, }, - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", @@ -2858,7 +2858,7 @@ func TestInstallRun_UpstreamDetection(t *testing.T) { IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", @@ -2892,7 +2892,7 @@ func TestInstallRun_UpstreamDetection(t *testing.T) { IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 99d6992b159..2142b29c0ae 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -425,7 +425,7 @@ func TestPreviewRun(t *testing.T) { tt.opts.IO = ios tt.opts.Prompter = &prompter.PrompterMock{} - tt.opts.Telemetry = &telemetry.NoOpInvocation{} + tt.opts.Telemetry = &telemetry.NoOpService{} err := previewRun(tt.opts) @@ -448,7 +448,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.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, }) require.ErrorContains(t, err, "does not currently support GitHub Enterprise Server") } @@ -510,7 +510,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.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -606,7 +606,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { Prompter: pm, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -690,7 +690,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { renderCalls++ return fmt.Sprintf("rendered:%s", filePath) }, - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -716,7 +716,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -823,7 +823,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -861,7 +861,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -894,7 +894,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -1253,7 +1253,7 @@ func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -1302,7 +1302,7 @@ func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { repo: ghrepo.New("owner", "repo"), SkillName: "hidden-skill", AllowHiddenDirs: true, - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -1347,7 +1347,7 @@ func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("owner", "repo"), SkillName: "hidden-skill", - Telemetry: &telemetry.NoOpInvocation{}, + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index c990f82e3a0..07b7d658794 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -374,7 +374,7 @@ func TestSearchRun(t *testing.T) { ios.SetStdoutTTY(tt.tty) ios.SetStderrTTY(tt.tty) tt.opts.IO = ios - tt.opts.Telemetry = &telemetry.NoOpInvocation{} + tt.opts.Telemetry = &telemetry.NoOpService{} defer reg.Verify(t) err := searchRun(tt.opts) From 570183b098592c5ee7ee0189f7a42c8a0148b873 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 8 Sep 2026 17:05:35 +0200 Subject: [PATCH 06/22] rename telemetry BeginEvent to Begin Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9452b3e7-02d8-4d4d-b2db-ab18b35657c1 --- internal/attachments/telemetry.go | 2 +- internal/gh/ghtelemetry/telemetry.go | 4 ++-- internal/telemetry/fake.go | 6 +++--- internal/telemetry/service.go | 8 ++++---- internal/telemetry/service_test.go | 14 +++++++------- internal/telemetry/telemetry_test.go | 4 ++-- pkg/cmdutil/telemetry.go | 2 +- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/internal/attachments/telemetry.go b/internal/attachments/telemetry.go index 07940f69e25..3b49d523da9 100644 --- a/internal/attachments/telemetry.go +++ b/internal/attachments/telemetry.go @@ -40,7 +40,7 @@ func (t *InvocationTelemetry) start(command string) { } t.recorder.SetSampleRate(ghtelemetry.SAMPLE_ALL) - t.event = t.recorder.BeginEvent(ghtelemetry.Event{ + t.event = t.recorder.Begin(ghtelemetry.Event{ Type: "attachment_invocation", Dimensions: ghtelemetry.Dimensions{ "command": command, diff --git a/internal/gh/ghtelemetry/telemetry.go b/internal/gh/ghtelemetry/telemetry.go index 83389a4561f..b9d1ea941bd 100644 --- a/internal/gh/ghtelemetry/telemetry.go +++ b/internal/gh/ghtelemetry/telemetry.go @@ -24,8 +24,8 @@ type Disabler interface { // EventRecorder produces complete or in-progress events. type EventRecorder interface { Record(event Event) - // BeginEvent records initial facts that can be updated until invocation completion. - BeginEvent(Event) PendingEvent + // Begin records initial facts that can be updated until invocation completion. + Begin(Event) PendingEvent } // InvocationRecorder produces events and controls invocation-wide reporting policy. diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go index da7ba9e1e61..ad00411b21a 100644 --- a/internal/telemetry/fake.go +++ b/internal/telemetry/fake.go @@ -24,12 +24,12 @@ func (r *EventRecorderSpy) Record(event ghtelemetry.Event) { if r.finished { return } - r.BeginEvent(event) + r.Begin(event) r.Events = append(r.Events, cloneEvent(event)) } -// BeginEvent captures initial facts and returns a handle for subsequent updates. -func (r *EventRecorderSpy) BeginEvent(event ghtelemetry.Event) ghtelemetry.PendingEvent { +// Begin captures initial facts and returns a handle for subsequent updates. +func (r *EventRecorderSpy) Begin(event ghtelemetry.Event) ghtelemetry.PendingEvent { if r.finished { return noOpPendingEvent{} } diff --git a/internal/telemetry/service.go b/internal/telemetry/service.go index 721638a7b3f..018316becad 100644 --- a/internal/telemetry/service.go +++ b/internal/telemetry/service.go @@ -110,9 +110,9 @@ func (s *Service) Record(event ghtelemetry.Event) { }) } -// BeginEvent copies an event's initial facts and returns a handle for adding +// Begin copies an event's initial facts and returns a handle for adding // facts until Finish. Events begun after Finish are not recorded. -func (s *Service) BeginEvent(event ghtelemetry.Event) ghtelemetry.PendingEvent { +func (s *Service) Begin(event ghtelemetry.Event) ghtelemetry.PendingEvent { s.mu.Lock() defer s.mu.Unlock() @@ -234,8 +234,8 @@ type NoOpService struct{} // Record discards the event. func (*NoOpService) Record(ghtelemetry.Event) {} -// BeginEvent returns an inert handle without retaining the event. -func (*NoOpService) BeginEvent(ghtelemetry.Event) ghtelemetry.PendingEvent { +// Begin returns an inert handle without retaining the event. +func (*NoOpService) Begin(ghtelemetry.Event) ghtelemetry.PendingEvent { return noOpPendingEvent{} } diff --git a/internal/telemetry/service_test.go b/internal/telemetry/service_test.go index 3093160a1a8..44eb8108b3a 100644 --- a/internal/telemetry/service_test.go +++ b/internal/telemetry/service_test.go @@ -24,7 +24,7 @@ func TestServiceCopiesFactsAtTheirRecordingTime(t *testing.T) { Measures: ghtelemetry.Measures{"count": 1}, } startedAt := time.Now() - pending := service.BeginEvent(facts) + pending := service.Begin(facts) time.Sleep(time.Second) facts.Type = "completed_step" service.Record(facts) @@ -63,7 +63,7 @@ func TestServicePromotesAllEventsBeforeCompletion(t *testing.T) { var payload SendTelemetryPayload service := NewService(func(p SendTelemetryPayload) { payload = p }, WithSampleRate(1)) service.Record(ghtelemetry.Event{Type: "completed_step"}) - pending := service.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) + pending := service.Begin(ghtelemetry.Event{Type: "attachment_invocation"}) // When attachment usage promotes the invocation before it finishes service.SetSampleRate(ghtelemetry.SAMPLE_ALL) @@ -87,7 +87,7 @@ func TestServiceDisablingOverridesPromotedPendingEvents(t *testing.T) { var payloads []SendTelemetryPayload service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) service.Record(ghtelemetry.Event{Type: "completed_step"}) - pending := service.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) + pending := service.Begin(ghtelemetry.Event{Type: "attachment_invocation"}) service.SetSampleRate(ghtelemetry.SAMPLE_ALL) // When host discovery disables telemetry before completion @@ -107,7 +107,7 @@ func TestServiceCompletionCannotBeReopened(t *testing.T) { // Given a completed invocation with one pending event var payloads []SendTelemetryPayload service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - pending := service.BeginEvent(ghtelemetry.Event{ + pending := service.Begin(ghtelemetry.Event{ Type: "command_invocation", Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, }) @@ -116,7 +116,7 @@ func TestServiceCompletionCannotBeReopened(t *testing.T) { // When cleanup repeats or code holding an old handle attempts further recording pending.SetDimensions(ghtelemetry.Dimensions{"command": "changed"}) service.Record(ghtelemetry.Event{Type: "too_late"}) - late := service.BeginEvent(ghtelemetry.Event{Type: "also_too_late"}) + late := service.Begin(ghtelemetry.Event{Type: "also_too_late"}) late.SetDimensions(ghtelemetry.Dimensions{"command": "changed"}) late.SetMeasures(ghtelemetry.Measures{"count": 1}) service.Finish() @@ -141,7 +141,7 @@ func TestServiceCleanupDuringSendCannotChangePayload(t *testing.T) { <-allowSend payloads = append(payloads, payload) }) - pending := service.BeginEvent(ghtelemetry.Event{ + pending := service.Begin(ghtelemetry.Event{ Type: "attachment_invocation", Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, Measures: ghtelemetry.Measures{"append_ops_count": 1}, @@ -185,7 +185,7 @@ func TestServiceCollectsConcurrentFacts(t *testing.T) { // Given two command activities contributing to the same pending event var payload SendTelemetryPayload service := NewService(func(p SendTelemetryPayload) { payload = p }) - pending := service.BeginEvent(ghtelemetry.Event{Type: "attachment_invocation"}) + pending := service.Begin(ghtelemetry.Event{Type: "attachment_invocation"}) // When both activities finish before command completion var workers sync.WaitGroup diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 4be380101bf..1421b7a3746 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -357,7 +357,7 @@ func TestServiceFinishSendsPendingEvents(t *testing.T) { service := NewService(func(payload SendTelemetryPayload) { payloads = append(payloads, payload) }) - event := service.BeginEvent(ghtelemetry.Event{ + event := service.Begin(ghtelemetry.Event{ Type: "attachment_invocation", Measures: ghtelemetry.Measures{ "attach_count": 2, @@ -702,7 +702,7 @@ func TestNoOpService(t *testing.T) { service := &NoOpService{} // All methods should be safe to call without panicking service.Record(ghtelemetry.Event{Type: "test"}) - event := service.BeginEvent(ghtelemetry.Event{Type: "pending"}) + event := service.Begin(ghtelemetry.Event{Type: "pending"}) event.SetDimensions(ghtelemetry.Dimensions{"key": "value"}) event.SetMeasures(ghtelemetry.Measures{"count": 1}) service.Disable() diff --git a/pkg/cmdutil/telemetry.go b/pkg/cmdutil/telemetry.go index 99d959df53d..4f32d1a1b25 100644 --- a/pkg/cmdutil/telemetry.go +++ b/pkg/cmdutil/telemetry.go @@ -22,7 +22,7 @@ func RecordTelemetry(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) { var event ghtelemetry.PendingEvent currentArgs := cmd.Args cmd.Args = func(cmd *cobra.Command, args []string) error { - event = telemetry.BeginEvent(ghtelemetry.Event{ + event = telemetry.Begin(ghtelemetry.Event{ Type: "command_invocation", Dimensions: ghtelemetry.Dimensions{ "command": cmd.CommandPath(), From 07105963fbf3f8157cacea3843c4531dfba321be Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 8 Sep 2026 17:09:05 +0200 Subject: [PATCH 07/22] move service back into telemetry.go Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9452b3e7-02d8-4d4d-b2db-ab18b35657c1 --- internal/telemetry/service.go | 249 -------------------------------- internal/telemetry/telemetry.go | 243 +++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 249 deletions(-) delete mode 100644 internal/telemetry/service.go diff --git a/internal/telemetry/service.go b/internal/telemetry/service.go deleted file mode 100644 index 018316becad..00000000000 --- a/internal/telemetry/service.go +++ /dev/null @@ -1,249 +0,0 @@ -package telemetry - -import ( - "encoding/binary" - "maps" - "runtime" - "strconv" - "sync" - "time" - - "github.com/cli/cli/v2/internal/gh/ghtelemetry" - "github.com/google/uuid" -) - -var ( - _ ghtelemetry.Service = (*Service)(nil) - _ ghtelemetry.Service = (*NoOpService)(nil) -) - -// Service records telemetry facts and reporting policy for one command execution. -// Finish must run after command execution to send the completed payload. -type Service struct { - mu sync.Mutex - send func(SendTelemetryPayload) - commonDimensions ghtelemetry.Dimensions - sampleRate int - sampleBucket byte - events []*invocationEvent - disabled bool - finished bool -} - -type invocationEvent struct { - event ghtelemetry.Event - recordedAt time.Time -} - -type pendingEvent struct { - service *Service - recorded *invocationEvent -} - -type serviceOptions struct { - additionalDimensions ghtelemetry.Dimensions - sampleRate int -} - -type serviceOption func(*serviceOptions) - -// WithAdditionalCommonDimensions sets dimensions shared by every invocation event. -func WithAdditionalCommonDimensions(dimensions ghtelemetry.Dimensions) serviceOption { - return func(options *serviceOptions) { - maps.Copy(options.additionalDimensions, dimensions) - } -} - -// WithSampleRate selects invocation-wide sampling. Rates 0 and 100 retain all -// events; rates between them select a percentage using the invocation ID. -func WithSampleRate(rate int) serviceOption { - return func(options *serviceOptions) { - options.sampleRate = rate - } -} - -// NewService creates a telemetry service using send to deliver its completed payload. -func NewService(send func(SendTelemetryPayload), opts ...serviceOption) *Service { - options := serviceOptions{ - additionalDimensions: make(ghtelemetry.Dimensions), - } - for _, opt := range opts { - opt(&options) - } - - deviceID, err := deviceIDFunc() - if err != nil { - deviceID = "" - } - invocationID := uuid.NewString() - commonDimensions := ghtelemetry.Dimensions{ - "device_id": deviceID, - "invocation_id": invocationID, - "os": runtime.GOOS, - "architecture": runtime.GOARCH, - } - maps.Copy(commonDimensions, options.additionalDimensions) - - hash := uuid.NewSHA1(uuid.Nil, []byte(invocationID)) - sampleBucket := byte(binary.BigEndian.Uint32(hash[:4]) % 100) - - return &Service{ - send: send, - commonDimensions: commonDimensions, - sampleRate: options.sampleRate, - sampleBucket: sampleBucket, - } -} - -// Record copies a complete event into the service. -// Recording after Finish has no effect. -func (s *Service) Record(event ghtelemetry.Event) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.finished { - return - } - s.events = append(s.events, &invocationEvent{ - event: cloneEvent(event), - recordedAt: time.Now(), - }) -} - -// Begin copies an event's initial facts and returns a handle for adding -// facts until Finish. Events begun after Finish are not recorded. -func (s *Service) Begin(event ghtelemetry.Event) ghtelemetry.PendingEvent { - s.mu.Lock() - defer s.mu.Unlock() - - if s.finished { - return noOpPendingEvent{} - } - recorded := &invocationEvent{ - event: cloneEvent(event), - recordedAt: time.Now(), - } - s.events = append(s.events, recorded) - return &pendingEvent{service: s, recorded: recorded} -} - -func (p *pendingEvent) SetDimensions(dimensions ghtelemetry.Dimensions) { - p.service.mu.Lock() - defer p.service.mu.Unlock() - - if p.service.finished { - return - } - if p.recorded.event.Dimensions == nil { - p.recorded.event.Dimensions = make(ghtelemetry.Dimensions) - } - maps.Copy(p.recorded.event.Dimensions, dimensions) -} - -func (p *pendingEvent) SetMeasures(measures ghtelemetry.Measures) { - p.service.mu.Lock() - defer p.service.mu.Unlock() - - if p.service.finished { - return - } - if p.recorded.event.Measures == nil { - p.recorded.event.Measures = make(ghtelemetry.Measures) - } - maps.Copy(p.recorded.event.Measures, measures) -} - -// SetSampleRate selects the sampling policy for the whole invocation. -// Changes after Finish have no effect. -func (s *Service) SetSampleRate(rate int) { - s.mu.Lock() - defer s.mu.Unlock() - - if s.finished { - return - } - s.sampleRate = rate - s.commonDimensions["sample_rate"] = strconv.Itoa(rate) -} - -// Disable suppresses all events in the invocation, including already recorded -// events. It must be called before Finish. -func (s *Service) Disable() { - s.mu.Lock() - defer s.mu.Unlock() - - s.disabled = true -} - -// Finish snapshots the recorded events once and sends their payload after releasing the lock. -// Sampling and telemetry eligibility apply to immediate and pending events alike. -func (s *Service) Finish() { - s.mu.Lock() - - if s.finished { - s.mu.Unlock() - return - } - s.finished = true - - if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate { - s.mu.Unlock() - return - } - - events := s.events - if s.disabled { - events = nil - } - - // Keep an empty payload so log mode can explain that no telemetry will be sent. - payload := SendTelemetryPayload{Events: make([]PayloadEvent, len(events))} - for index, recorded := range 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[index] = PayloadEvent{ - Type: recorded.event.Type, - Dimensions: dimensions, - Measures: maps.Clone(recorded.event.Measures), - } - } - s.mu.Unlock() - - s.send(payload) -} - -func cloneEvent(event ghtelemetry.Event) ghtelemetry.Event { - return ghtelemetry.Event{ - Type: event.Type, - Dimensions: maps.Clone(event.Dimensions), - Measures: maps.Clone(event.Measures), - } -} - -type noOpPendingEvent struct{} - -func (noOpPendingEvent) SetDimensions(ghtelemetry.Dimensions) {} -func (noOpPendingEvent) SetMeasures(ghtelemetry.Measures) {} - -// NoOpService discards telemetry when collection is disabled. -type NoOpService struct{} - -// Record discards the event. -func (*NoOpService) Record(ghtelemetry.Event) {} - -// Begin returns an inert handle without retaining the event. -func (*NoOpService) Begin(ghtelemetry.Event) ghtelemetry.PendingEvent { - return noOpPendingEvent{} -} - -// Disable leaves telemetry disabled. -func (*NoOpService) Disable() {} - -// SetSampleRate leaves telemetry disabled. -func (*NoOpService) SetSampleRate(int) {} - -// Finish has no payload to complete. -func (*NoOpService) Finish() {} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 5e943113dc5..1d3d405f7e7 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -4,17 +4,24 @@ package telemetry import ( "bytes" + "encoding/binary" "encoding/json" "errors" "fmt" "io" + "maps" "os" "os/exec" "path/filepath" + "runtime" "slices" + "strconv" "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" @@ -138,6 +145,28 @@ func ParseTelemetryState(configValue string) TelemetryState { return Enabled } +type serviceOptions struct { + additionalDimensions ghtelemetry.Dimensions + sampleRate int +} + +type serviceOption func(*serviceOptions) + +// WithAdditionalCommonDimensions sets dimensions shared by every invocation event. +func WithAdditionalCommonDimensions(dimensions ghtelemetry.Dimensions) serviceOption { + return func(options *serviceOptions) { + maps.Copy(options.additionalDimensions, dimensions) + } +} + +// WithSampleRate selects invocation-wide sampling. Rates 0 and 100 retain all +// events; rates between them select a percentage using the invocation ID. +func WithSampleRate(rate int) serviceOption { + return func(options *serviceOptions) { + options.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. // 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) { @@ -180,6 +209,195 @@ var GitHubFlusher = func(executable string) func(payload SendTelemetryPayload) { } } +// NewService creates a telemetry service using send to deliver its completed payload. +func NewService(send func(SendTelemetryPayload), opts ...serviceOption) *Service { + options := serviceOptions{ + additionalDimensions: make(ghtelemetry.Dimensions), + } + for _, opt := range opts { + opt(&options) + } + + deviceID, err := deviceIDFunc() + if err != nil { + deviceID = "" + } + invocationID := uuid.NewString() + commonDimensions := ghtelemetry.Dimensions{ + "device_id": deviceID, + "invocation_id": invocationID, + "os": runtime.GOOS, + "architecture": runtime.GOARCH, + } + maps.Copy(commonDimensions, options.additionalDimensions) + + hash := uuid.NewSHA1(uuid.Nil, []byte(invocationID)) + sampleBucket := byte(binary.BigEndian.Uint32(hash[:4]) % 100) + + return &Service{ + send: send, + commonDimensions: commonDimensions, + sampleRate: options.sampleRate, + sampleBucket: sampleBucket, + } +} + +type invocationEvent struct { + event ghtelemetry.Event + recordedAt time.Time +} + +var ( + _ ghtelemetry.Service = (*Service)(nil) + _ ghtelemetry.Service = (*NoOpService)(nil) +) + +// Service records telemetry facts and reporting policy for one command execution. +// Finish must run after command execution to send the completed payload. +type Service struct { + mu sync.Mutex + send func(SendTelemetryPayload) + commonDimensions ghtelemetry.Dimensions + sampleRate int + sampleBucket byte + events []*invocationEvent + disabled bool + finished bool +} + +type pendingEvent struct { + service *Service + recorded *invocationEvent +} + +// Disable suppresses all events in the invocation, including already recorded +// events. It must be called before Finish. +func (s *Service) Disable() { + s.mu.Lock() + defer s.mu.Unlock() + + s.disabled = true +} + +// Record copies a complete event into the service. +// Recording after Finish has no effect. +func (s *Service) Record(event ghtelemetry.Event) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.finished { + return + } + s.events = append(s.events, &invocationEvent{ + event: cloneEvent(event), + recordedAt: time.Now(), + }) +} + +// Begin copies an event's initial facts and returns a handle for adding +// facts until Finish. Events begun after Finish are not recorded. +func (s *Service) Begin(event ghtelemetry.Event) ghtelemetry.PendingEvent { + s.mu.Lock() + defer s.mu.Unlock() + + if s.finished { + return noOpPendingEvent{} + } + recorded := &invocationEvent{ + event: cloneEvent(event), + recordedAt: time.Now(), + } + s.events = append(s.events, recorded) + return &pendingEvent{service: s, recorded: recorded} +} + +func (p *pendingEvent) SetDimensions(dimensions ghtelemetry.Dimensions) { + p.service.mu.Lock() + defer p.service.mu.Unlock() + + if p.service.finished { + return + } + if p.recorded.event.Dimensions == nil { + p.recorded.event.Dimensions = make(ghtelemetry.Dimensions) + } + maps.Copy(p.recorded.event.Dimensions, dimensions) +} + +func (p *pendingEvent) SetMeasures(measures ghtelemetry.Measures) { + p.service.mu.Lock() + defer p.service.mu.Unlock() + + if p.service.finished { + return + } + if p.recorded.event.Measures == nil { + p.recorded.event.Measures = make(ghtelemetry.Measures) + } + maps.Copy(p.recorded.event.Measures, measures) +} + +// SetSampleRate selects the sampling policy for the whole invocation. +// Changes after Finish have no effect. +func (s *Service) SetSampleRate(rate int) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.finished { + return + } + s.sampleRate = rate + s.commonDimensions["sample_rate"] = strconv.Itoa(rate) +} + +// Finish snapshots the recorded events once and sends their payload after releasing the lock. +// Sampling and telemetry eligibility apply to immediate and pending events alike. +func (s *Service) Finish() { + s.mu.Lock() + + if s.finished { + s.mu.Unlock() + return + } + s.finished = true + + if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate { + s.mu.Unlock() + return + } + + events := s.events + if s.disabled { + events = nil + } + + // Keep an empty payload so log mode can explain that no telemetry will be sent. + payload := SendTelemetryPayload{Events: make([]PayloadEvent, len(events))} + for index, recorded := range 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[index] = PayloadEvent{ + Type: recorded.event.Type, + Dimensions: dimensions, + Measures: maps.Clone(recorded.event.Measures), + } + } + s.mu.Unlock() + + s.send(payload) +} + +func cloneEvent(event ghtelemetry.Event) ghtelemetry.Event { + return ghtelemetry.Event{ + Type: event.Type, + Dimensions: maps.Clone(event.Dimensions), + Measures: maps.Clone(event.Measures), + } +} + // 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). @@ -262,3 +480,28 @@ func SpawnSendTelemetry(executable string, payload SendTelemetryPayload) { // Release resources associated with the child process since we will never Wait for it. _ = cmd.Process.Release() } + +type noOpPendingEvent struct{} + +func (noOpPendingEvent) SetDimensions(ghtelemetry.Dimensions) {} +func (noOpPendingEvent) SetMeasures(ghtelemetry.Measures) {} + +// NoOpService discards telemetry when collection is disabled. +type NoOpService struct{} + +// Record discards the event. +func (*NoOpService) Record(ghtelemetry.Event) {} + +// Begin returns an inert handle without retaining the event. +func (*NoOpService) Begin(ghtelemetry.Event) ghtelemetry.PendingEvent { + return noOpPendingEvent{} +} + +// Disable leaves telemetry disabled. +func (*NoOpService) Disable() {} + +// SetSampleRate leaves telemetry disabled. +func (*NoOpService) SetSampleRate(int) {} + +// Finish has no payload to complete. +func (*NoOpService) Finish() {} From 3c517fa04080ced85424bfbe036201b0418fda3d Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 8 Sep 2026 17:13:25 +0200 Subject: [PATCH 08/22] limit telemetry disabling to the service Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9452b3e7-02d8-4d4d-b2db-ab18b35657c1 --- internal/gh/ghtelemetry/telemetry.go | 4 ++-- internal/telemetry/fake.go | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/internal/gh/ghtelemetry/telemetry.go b/internal/gh/ghtelemetry/telemetry.go index b9d1ea941bd..93f088d3393 100644 --- a/internal/gh/ghtelemetry/telemetry.go +++ b/internal/gh/ghtelemetry/telemetry.go @@ -28,16 +28,16 @@ type EventRecorder interface { Begin(Event) PendingEvent } -// InvocationRecorder produces events and controls invocation-wide reporting policy. +// InvocationRecorder produces events and controls invocation-wide sampling. type InvocationRecorder interface { EventRecorder - Disabler SetSampleRate(rate int) } // Service collects telemetry for one command execution and sends it on Finish. type Service interface { InvocationRecorder + Disabler Finish() } diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go index ad00411b21a..738de01d0aa 100644 --- a/internal/telemetry/fake.go +++ b/internal/telemetry/fake.go @@ -7,8 +7,8 @@ import ( ) var ( - _ ghtelemetry.EventRecorder = (*EventRecorderSpy)(nil) - _ ghtelemetry.Service = (*InvocationRecorderSpy)(nil) + _ ghtelemetry.EventRecorder = (*EventRecorderSpy)(nil) + _ ghtelemetry.InvocationRecorder = (*InvocationRecorderSpy)(nil) ) // EventRecorderSpy captures complete events immediately. Finish includes pending @@ -51,15 +51,12 @@ func (r *EventRecorderSpy) Finish() { r.events = nil } -// InvocationRecorderSpy adds invocation policy to EventRecorderSpy. +// InvocationRecorderSpy adds invocation sampling to EventRecorderSpy. type InvocationRecorderSpy struct { EventRecorderSpy LastSampleRate int } -// Disable leaves captured events available for assertions. -func (r *InvocationRecorderSpy) Disable() {} - // SetSampleRate captures the sampling policy requested by a command. func (r *InvocationRecorderSpy) SetSampleRate(rate int) { r.LastSampleRate = rate From 03acc10bb051537037a9abfb8b44de9e8a82db85 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 8 Sep 2026 17:21:33 +0200 Subject: [PATCH 09/22] name pending telemetry updates as upserts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9452b3e7-02d8-4d4d-b2db-ab18b35657c1 --- internal/attachments/telemetry.go | 2 +- internal/gh/ghtelemetry/telemetry.go | 7 ++++--- internal/telemetry/fake.go | 4 ++-- internal/telemetry/service_test.go | 26 +++++++++++++------------- internal/telemetry/telemetry.go | 8 ++++---- internal/telemetry/telemetry_test.go | 6 +++--- pkg/cmdutil/telemetry.go | 2 +- 7 files changed, 28 insertions(+), 27 deletions(-) diff --git a/internal/attachments/telemetry.go b/internal/attachments/telemetry.go index 3b49d523da9..424710d8d63 100644 --- a/internal/attachments/telemetry.go +++ b/internal/attachments/telemetry.go @@ -59,7 +59,7 @@ func (t *InvocationTelemetry) RecordOperations(result UploadResult) { return } - t.event.SetMeasures(ghtelemetry.Measures{ + t.event.UpsertMeasures(ghtelemetry.Measures{ "append_ops_count": int64(result.AppendOperations), "replace_ops_count": int64(result.ReplaceOperations), }) diff --git a/internal/gh/ghtelemetry/telemetry.go b/internal/gh/ghtelemetry/telemetry.go index 93f088d3393..70c0e056359 100644 --- a/internal/gh/ghtelemetry/telemetry.go +++ b/internal/gh/ghtelemetry/telemetry.go @@ -11,10 +11,11 @@ type Event struct { } // PendingEvent accepts additional facts until its invocation finishes. -// Setters copy their input and have no effect after completion. +// Upserts copy supplied entries, inserting new keys and overwriting existing ones. +// Unspecified keys are unchanged. Calls after completion have no effect. type PendingEvent interface { - SetDimensions(Dimensions) - SetMeasures(Measures) + UpsertDimensions(Dimensions) + UpsertMeasures(Measures) } type Disabler interface { diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go index 738de01d0aa..a4ac43677f5 100644 --- a/internal/telemetry/fake.go +++ b/internal/telemetry/fake.go @@ -67,7 +67,7 @@ type pendingEventSpy struct { event *ghtelemetry.Event } -func (p *pendingEventSpy) SetDimensions(dimensions ghtelemetry.Dimensions) { +func (p *pendingEventSpy) UpsertDimensions(dimensions ghtelemetry.Dimensions) { if p.recorder.finished { return } @@ -77,7 +77,7 @@ func (p *pendingEventSpy) SetDimensions(dimensions ghtelemetry.Dimensions) { maps.Copy(p.event.Dimensions, dimensions) } -func (p *pendingEventSpy) SetMeasures(measures ghtelemetry.Measures) { +func (p *pendingEventSpy) UpsertMeasures(measures ghtelemetry.Measures) { if p.recorder.finished { return } diff --git a/internal/telemetry/service_test.go b/internal/telemetry/service_test.go index 44eb8108b3a..745c6324a2a 100644 --- a/internal/telemetry/service_test.go +++ b/internal/telemetry/service_test.go @@ -34,8 +34,8 @@ func TestServiceCopiesFactsAtTheirRecordingTime(t *testing.T) { facts.Measures["count"] = 99 dimensions := ghtelemetry.Dimensions{"flags": "attach"} measures := ghtelemetry.Measures{"count": 2} - pending.SetDimensions(dimensions) - pending.SetMeasures(measures) + pending.UpsertDimensions(dimensions) + pending.UpsertMeasures(measures) dimensions["flags"] = "unrelated" measures["count"] = 99 time.Sleep(time.Second) @@ -67,7 +67,7 @@ func TestServicePromotesAllEventsBeforeCompletion(t *testing.T) { // When attachment usage promotes the invocation before it finishes service.SetSampleRate(ghtelemetry.SAMPLE_ALL) - pending.SetMeasures(ghtelemetry.Measures{"attach_count": 2}) + pending.UpsertMeasures(ghtelemetry.Measures{"attach_count": 2}) service.Finish() // Then immediate and pending events share the promoted sampling policy @@ -92,7 +92,7 @@ func TestServiceDisablingOverridesPromotedPendingEvents(t *testing.T) { // When host discovery disables telemetry before completion service.Disable() - pending.SetMeasures(ghtelemetry.Measures{"attach_count": 2}) + pending.UpsertMeasures(ghtelemetry.Measures{"attach_count": 2}) service.Record(ghtelemetry.Event{Type: "another_step"}) service.Finish() @@ -114,11 +114,11 @@ func TestServiceCompletionCannotBeReopened(t *testing.T) { service.Finish() // When cleanup repeats or code holding an old handle attempts further recording - pending.SetDimensions(ghtelemetry.Dimensions{"command": "changed"}) + pending.UpsertDimensions(ghtelemetry.Dimensions{"command": "changed"}) service.Record(ghtelemetry.Event{Type: "too_late"}) late := service.Begin(ghtelemetry.Event{Type: "also_too_late"}) - late.SetDimensions(ghtelemetry.Dimensions{"command": "changed"}) - late.SetMeasures(ghtelemetry.Measures{"count": 1}) + late.UpsertDimensions(ghtelemetry.Dimensions{"command": "changed"}) + late.UpsertMeasures(ghtelemetry.Measures{"count": 1}) service.Finish() service.Finish() @@ -156,8 +156,8 @@ func TestServiceCleanupDuringSendCannotChangePayload(t *testing.T) { <-sendStarted cleanupDone := make(chan struct{}) go func() { - pending.SetDimensions(ghtelemetry.Dimensions{"command": "changed"}) - pending.SetMeasures(ghtelemetry.Measures{"append_ops_count": 99}) + pending.UpsertDimensions(ghtelemetry.Dimensions{"command": "changed"}) + pending.UpsertMeasures(ghtelemetry.Measures{"append_ops_count": 99}) service.Record(ghtelemetry.Event{Type: "too_late"}) service.Finish() close(cleanupDone) @@ -190,12 +190,12 @@ func TestServiceCollectsConcurrentFacts(t *testing.T) { // When both activities finish before command completion var workers sync.WaitGroup workers.Go(func() { - pending.SetDimensions(ghtelemetry.Dimensions{"command": "gh issue create"}) - pending.SetMeasures(ghtelemetry.Measures{"append_ops_count": 1}) + pending.UpsertDimensions(ghtelemetry.Dimensions{"command": "gh issue create"}) + pending.UpsertMeasures(ghtelemetry.Measures{"append_ops_count": 1}) }) workers.Go(func() { - pending.SetDimensions(ghtelemetry.Dimensions{"flags": "attach"}) - pending.SetMeasures(ghtelemetry.Measures{"replace_ops_count": 2}) + pending.UpsertDimensions(ghtelemetry.Dimensions{"flags": "attach"}) + pending.UpsertMeasures(ghtelemetry.Measures{"replace_ops_count": 2}) }) workers.Wait() service.Finish() diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 1d3d405f7e7..ec3314e594c 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -311,7 +311,7 @@ func (s *Service) Begin(event ghtelemetry.Event) ghtelemetry.PendingEvent { return &pendingEvent{service: s, recorded: recorded} } -func (p *pendingEvent) SetDimensions(dimensions ghtelemetry.Dimensions) { +func (p *pendingEvent) UpsertDimensions(dimensions ghtelemetry.Dimensions) { p.service.mu.Lock() defer p.service.mu.Unlock() @@ -324,7 +324,7 @@ func (p *pendingEvent) SetDimensions(dimensions ghtelemetry.Dimensions) { maps.Copy(p.recorded.event.Dimensions, dimensions) } -func (p *pendingEvent) SetMeasures(measures ghtelemetry.Measures) { +func (p *pendingEvent) UpsertMeasures(measures ghtelemetry.Measures) { p.service.mu.Lock() defer p.service.mu.Unlock() @@ -483,8 +483,8 @@ func SpawnSendTelemetry(executable string, payload SendTelemetryPayload) { type noOpPendingEvent struct{} -func (noOpPendingEvent) SetDimensions(ghtelemetry.Dimensions) {} -func (noOpPendingEvent) SetMeasures(ghtelemetry.Measures) {} +func (noOpPendingEvent) UpsertDimensions(ghtelemetry.Dimensions) {} +func (noOpPendingEvent) UpsertMeasures(ghtelemetry.Measures) {} // NoOpService discards telemetry when collection is disabled. type NoOpService struct{} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 1421b7a3746..0891320b069 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -369,7 +369,7 @@ func TestServiceFinishSendsPendingEvents(t *testing.T) { require.Empty(t, payloads, "unfinished events must not be sent") // When the command supplies its operations and finishes the invocation - event.SetMeasures(ghtelemetry.Measures{ + event.UpsertMeasures(ghtelemetry.Measures{ "append_ops_count": 1, "replace_ops_count": 1, }) @@ -703,8 +703,8 @@ func TestNoOpService(t *testing.T) { // All methods should be safe to call without panicking service.Record(ghtelemetry.Event{Type: "test"}) event := service.Begin(ghtelemetry.Event{Type: "pending"}) - event.SetDimensions(ghtelemetry.Dimensions{"key": "value"}) - event.SetMeasures(ghtelemetry.Measures{"count": 1}) + event.UpsertDimensions(ghtelemetry.Dimensions{"key": "value"}) + event.UpsertMeasures(ghtelemetry.Measures{"count": 1}) service.Disable() service.SetSampleRate(50) service.Finish() diff --git a/pkg/cmdutil/telemetry.go b/pkg/cmdutil/telemetry.go index 4f32d1a1b25..6bbdcabdc7c 100644 --- a/pkg/cmdutil/telemetry.go +++ b/pkg/cmdutil/telemetry.go @@ -39,7 +39,7 @@ func RecordTelemetry(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) { cmd.RunE = func(cmd *cobra.Command, args []string) error { runErr := currentRunE(cmd, args) // Commands with DisableFlagParsing may parse their flags inside RunE. - event.SetDimensions(ghtelemetry.Dimensions{"flags": telemetryFlags(cmd)}) + event.UpsertDimensions(ghtelemetry.Dimensions{"flags": telemetryFlags(cmd)}) return runErr } } From 2ac2543e339aebe1ba0da4a56dcf086de181450f Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 8 Sep 2026 18:07:03 +0200 Subject: [PATCH 10/22] simplify attachment telemetry initialization Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9452b3e7-02d8-4d4d-b2db-ab18b35657c1 --- internal/attachments/flags_test.go | 47 ++-------------------- internal/attachments/telemetry.go | 57 +++++++-------------------- internal/attachments/test.go | 9 ++--- pkg/cmd/issue/comment/comment.go | 7 ++-- pkg/cmd/issue/comment/comment_test.go | 44 +++++++++++++++++++++ pkg/cmd/issue/create/create.go | 15 +++---- pkg/cmd/issue/create/create_test.go | 2 +- pkg/cmd/issue/edit/edit.go | 17 ++++---- pkg/cmd/issue/edit/edit_test.go | 2 +- pkg/cmd/pr/comment/comment.go | 7 ++-- pkg/cmd/pr/create/create.go | 15 +++---- pkg/cmd/pr/create/create_test.go | 2 +- pkg/cmd/pr/edit/edit.go | 17 ++++---- pkg/cmd/pr/edit/edit_test.go | 2 +- pkg/cmd/pr/shared/commentable.go | 5 ++- pkg/cmd/pr/shared/commentable_test.go | 2 +- 16 files changed, 114 insertions(+), 136 deletions(-) diff --git a/internal/attachments/flags_test.go b/internal/attachments/flags_test.go index 2cc5bbd0e7d..da29c86738e 100644 --- a/internal/attachments/flags_test.go +++ b/internal/attachments/flags_test.go @@ -1,7 +1,6 @@ package attachments import ( - "errors" "fmt" "io/fs" "os" @@ -91,7 +90,7 @@ func TestAddFlag(t *testing.T) { } } -func TestInvocationTelemetry(t *testing.T) { +func TestAttachmentTelemetry(t *testing.T) { tests := []struct { name string input string @@ -147,12 +146,11 @@ func TestInvocationTelemetry(t *testing.T) { t.Run(tt.name, func(t *testing.T) { _, attachFlag := attachCmd(t, tt.input) recorder := &telemetry.InvocationRecorderSpy{} - invocationTelemetry := NewInvocationTelemetry(attachFlag, recorder) - invocationTelemetry.start("gh issue comment") + event := BeginTelemetry(attachFlag, recorder, "gh issue comment") assert.Empty(t, recorder.Events) if tt.operations != nil { - invocationTelemetry.RecordOperations(*tt.operations) + RecordOperations(event, *tt.operations) } if tt.wantValidationErr != "" { _, err := attachFlag.UserAssets() @@ -188,45 +186,6 @@ func TestInvocationTelemetry(t *testing.T) { } } -func TestInvocationTelemetryWrapArgsRecordsBeforePersistentPreRunError(t *testing.T) { - recorder := &telemetry.InvocationRecorderSpy{} - cmd := &cobra.Command{ - Use: "comment", - Args: cobra.ExactArgs(1), - RunE: func(*cobra.Command, []string) error { - return errors.New("run should not be called") - }, - } - attachFlag := AddFlag(cmd) - invocationTelemetry := NewInvocationTelemetry(attachFlag, recorder) - cmd.Args = invocationTelemetry.WrapArgs(cmd.Args) - - root := &cobra.Command{ - Use: "gh", - SilenceErrors: true, - SilenceUsage: true, - PersistentPreRunE: func(*cobra.Command, []string) error { - return errors.New("authentication failed") - }, - } - root.AddCommand(cmd) - root.SetArgs([]string{"comment", "1", "--attach", "./shot.png"}) - - _, err := root.ExecuteC() - require.EqualError(t, err, "authentication failed") - recorder.Finish() - - require.Equal(t, []ghtelemetry.Event{{ - Type: "attachment_invocation", - Dimensions: ghtelemetry.Dimensions{"command": "gh comment"}, - Measures: ghtelemetry.Measures{ - "attach_count": 1, - "append_ops_count": 0, - "replace_ops_count": 0, - }, - }}, recorder.Events) -} - func TestFlagUserAssets(t *testing.T) { tests := []struct { name string diff --git a/internal/attachments/telemetry.go b/internal/attachments/telemetry.go index 424710d8d63..c72de2321b1 100644 --- a/internal/attachments/telemetry.go +++ b/internal/attachments/telemetry.go @@ -1,65 +1,36 @@ package attachments -import ( - "github.com/cli/cli/v2/internal/gh/ghtelemetry" - "github.com/spf13/cobra" -) +import "github.com/cli/cli/v2/internal/gh/ghtelemetry" -// InvocationTelemetry records telemetry for one command invocation using -// attachments. -type InvocationTelemetry struct { - flag *Flag - recorder ghtelemetry.InvocationRecorder - event ghtelemetry.PendingEvent -} - -// NewInvocationTelemetry creates attachment telemetry for flag. -func NewInvocationTelemetry(flag *Flag, recorder ghtelemetry.InvocationRecorder) *InvocationTelemetry { - return &InvocationTelemetry{ - flag: flag, - recorder: recorder, - } -} - -// WrapArgs starts attachment telemetry before argument validation. -func (t *InvocationTelemetry) WrapArgs(validate cobra.PositionalArgs) cobra.PositionalArgs { - return func(cmd *cobra.Command, args []string) error { - t.start(cmd.CommandPath()) - return validate(cmd, args) - } -} - -func (t *InvocationTelemetry) start(command string) { - if t == nil { - return - } - - t.event = nil - if t.recorder == nil || t.flag == nil || !t.flag.Changed() { - return +// BeginTelemetry records attachment usage before validation and promotes full sampling. +// It returns nil if the flag was not passed or the flag or recorder is absent. +func BeginTelemetry(flag *Flag, recorder ghtelemetry.InvocationRecorder, command string) ghtelemetry.PendingEvent { + if recorder == nil || flag == nil || !flag.Changed() { + return nil } - t.recorder.SetSampleRate(ghtelemetry.SAMPLE_ALL) - t.event = t.recorder.Begin(ghtelemetry.Event{ + recorder.SetSampleRate(ghtelemetry.SAMPLE_ALL) + return recorder.Begin(ghtelemetry.Event{ Type: "attachment_invocation", Dimensions: ghtelemetry.Dimensions{ "command": command, }, Measures: ghtelemetry.Measures{ - "attach_count": int64(len(t.flag.values)), + "attach_count": int64(len(flag.values)), "append_ops_count": 0, "replace_ops_count": 0, }, }) } -// RecordOperations adds successful markdown operations to the invocation. -func (t *InvocationTelemetry) RecordOperations(result UploadResult) { - if t == nil || t.event == nil { +// RecordOperations upserts completed markdown operation counts, including partial results. +// A nil event means there is no attachment telemetry to update. +func RecordOperations(event ghtelemetry.PendingEvent, result UploadResult) { + if event == nil { return } - t.event.UpsertMeasures(ghtelemetry.Measures{ + event.UpsertMeasures(ghtelemetry.Measures{ "append_ops_count": int64(result.AppendOperations), "replace_ops_count": int64(result.ReplaceOperations), }) diff --git a/internal/attachments/test.go b/internal/attachments/test.go index 8659209e61b..f93e29e7fc8 100644 --- a/internal/attachments/test.go +++ b/internal/attachments/test.go @@ -40,9 +40,8 @@ func NewTestAssets(t *testing.T, names ...string) []UserAsset { return assets } -// NewTestInvocationTelemetry returns telemetry started with the given -// attachment count. -func NewTestInvocationTelemetry(t *testing.T, recorder ghtelemetry.InvocationRecorder, attachCount int) *InvocationTelemetry { +// BeginTestTelemetry returns a pending event with the given attachment count. +func BeginTestTelemetry(t *testing.T, recorder ghtelemetry.InvocationRecorder, attachCount int) ghtelemetry.PendingEvent { t.Helper() cmd := &cobra.Command{Use: "test"} @@ -50,9 +49,7 @@ func NewTestInvocationTelemetry(t *testing.T, recorder ghtelemetry.InvocationRec for i := range attachCount { require.NoError(t, cmd.Flags().Set(flagName, "attachment-"+strconv.Itoa(i)+".png")) } - invocationTelemetry := NewInvocationTelemetry(attachFlag, recorder) - invocationTelemetry.start("gh test") - return invocationTelemetry + return BeginTelemetry(attachFlag, recorder, "gh test") } // AssertTestTelemetryEvents verifies the completed invocation event shape. diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index e2f2ecbcfd0..868da8120cf 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -57,7 +57,10 @@ func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, # Attach multiple files by repeating the flag $ gh issue comment 12 --attach ./before.png --attach ./after.png `), - Args: cobra.ExactArgs(1), + Args: func(cmd *cobra.Command, args []string) error { + opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + return cobra.ExactArgs(1)(cmd, args) + }, PreRunE: func(cmd *cobra.Command, args []string) error { opts.RetrieveCommentable = func() (prShared.Commentable, ghrepo.Interface, error) { // TODO wm: more testing @@ -134,8 +137,6 @@ func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, cmd.Flags().BoolVar(&opts.DeleteLastConfirmed, "yes", false, "Skip the delete confirmation prompt when --delete-last is provided") cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last") opts.AttachFlag = attachments.AddFlag(cmd) - opts.AttachTelemetry = attachments.NewInvocationTelemetry(opts.AttachFlag, telemetry) - cmd.Args = opts.AttachTelemetry.WrapArgs(cmd.Args) return cmd } diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index 863e66df480..693d3dd01c8 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -22,6 +22,7 @@ import ( "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -480,6 +481,49 @@ func TestNewCmdComment(t *testing.T) { } } +func TestNewCmdCommentRecordsAttachmentsBeforePersistentPreRunError(t *testing.T) { + t.Parallel() + + // Given an attachment command whose parent rejects execution before PreRunE + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Browser: &browser.Stub{}, + Config: testConfig(), + } + recorder := &telemetry.InvocationRecorderSpy{} + cmd := NewCmdComment(f, recorder, func(*shared.CommentableOptions) error { + return errors.New("run should not be called") + }) + root := &cobra.Command{ + Use: "gh", + SilenceErrors: true, + SilenceUsage: true, + PersistentPreRunE: func(*cobra.Command, []string) error { + return errors.New("authentication failed") + }, + } + root.AddCommand(cmd) + root.SetArgs([]string{"comment", "1", "--attach", "./shot.png"}) + + // When authentication fails and telemetry is completed + _, err := root.ExecuteC() + require.EqualError(t, err, "authentication failed") + recorder.Finish() + + // Then the attempted attachment is retained without completed operations + assert.Equal(t, ghtelemetry.SAMPLE_ALL, recorder.LastSampleRate) + assert.Equal(t, []ghtelemetry.Event{{ + Type: "attachment_invocation", + Dimensions: ghtelemetry.Dimensions{"command": "gh comment"}, + Measures: ghtelemetry.Measures{ + "attach_count": 1, + "append_ops_count": 0, + "replace_ops_count": 0, + }, + }}, recorder.Events) +} + func Test_commentRun(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index dc5b636cdc9..0d2300303ab 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -58,9 +58,9 @@ type CreateOptions struct { BlockedBy []string Blocking []string - AttachFlag *attachments.Flag - AttachTelemetry *attachments.InvocationTelemetry - Assets []attachments.UserAsset + AttachFlag *attachments.Flag + AttachEvent ghtelemetry.PendingEvent + Assets []attachments.UserAsset } func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, runF func(*CreateOptions) error) *cobra.Command { @@ -119,7 +119,10 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, $ gh issue create --parent https://github.com/cli/go-gh/issues/42 $ gh issue create --blocked-by 200,201 --blocking 300 `), - Args: cmdutil.NoArgsQuoteReminder, + Args: func(cmd *cobra.Command, args []string) error { + opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + return cmdutil.NoArgsQuoteReminder(cmd, args) + }, Aliases: []string{"new"}, RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override @@ -193,8 +196,6 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, cmd.Flags().StringSliceVar(&opts.BlockedBy, "blocked-by", nil, "Mark the new issue as blocked by these issue `numbers` or URLs") cmd.Flags().StringSliceVar(&opts.Blocking, "blocking", nil, "Mark the new issue as blocking these issue `numbers` or URLs") opts.AttachFlag = attachments.AddFlag(cmd) - opts.AttachTelemetry = attachments.NewInvocationTelemetry(opts.AttachFlag, telemetry) - cmd.Args = opts.AttachTelemetry.WrapArgs(cmd.Args) return cmd } @@ -468,7 +469,7 @@ func createRun(opts *CreateOptions) (err error) { // issue is created and the failures are reported. if uploader != nil { body, uploadResult, uploadErr := uploader.UploadAndAttach(context.Background(), tb.Body, opts.Assets) - opts.AttachTelemetry.RecordOperations(uploadResult) + attachments.RecordOperations(opts.AttachEvent, uploadResult) if uploadErr != nil && uploadResult.Uploaded == 0 { err = uploadErr return diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index c7fd2c01667..b6294981bf6 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -1437,7 +1437,7 @@ func Test_createRun(t *testing.T) { } attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - opts.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(tt.attach)) + opts.AttachEvent = attachments.BeginTestTelemetry(t, attachmentRecorder, len(tt.attach)) } opts.Config = func() (gh.Config, error) { cfg := tt.config diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 468a3061c13..7bb4e23d4b1 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -51,10 +51,10 @@ type EditOptions struct { AddBlocking []string RemoveBlocking []string - AttachFlag *attachments.Flag - AttachTelemetry *attachments.InvocationTelemetry - Assets []attachments.UserAsset - Config func() (gh.Config, error) + AttachFlag *attachments.Flag + AttachEvent ghtelemetry.PendingEvent + Assets []attachments.UserAsset + Config func() (gh.Config, error) prShared.Editable } @@ -122,7 +122,10 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, ru $ gh issue edit 100 --add-sub-issue 123,124 $ gh issue edit 123 --add-blocked-by 200 --add-blocking 300,301 `), - Args: cobra.MinimumNArgs(1), + Args: func(cmd *cobra.Command, args []string) error { + opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + return cobra.MinimumNArgs(1)(cmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { issueNumbers, baseRepo, err := issueShared.ParseIssuesFromArgs(args) if err != nil { @@ -273,8 +276,6 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, ru cmd.Flags().StringSliceVar(&opts.AddBlocking, "add-blocking", nil, "Add 'blocking' relationships by issue `number` or URL") cmd.Flags().StringSliceVar(&opts.RemoveBlocking, "remove-blocking", nil, "Remove 'blocking' relationships by issue `number` or URL") opts.AttachFlag = attachments.AddFlag(cmd) - opts.AttachTelemetry = attachments.NewInvocationTelemetry(opts.AttachFlag, telemetry) - cmd.Args = opts.AttachTelemetry.WrapArgs(cmd.Args) return cmd } @@ -421,7 +422,7 @@ func editRun(opts *EditOptions) error { // prompt or cancel may follow an upload. var uploadResult attachments.UploadResult body, uploadResult, uploadErr = uploader.UploadAndAttach(context.Background(), body, opts.Assets) - opts.AttachTelemetry.RecordOperations(uploadResult) + attachments.RecordOperations(opts.AttachEvent, uploadResult) if uploadResult.Uploaded > 0 { editable.Body.Value = body diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 704114cb989..ae4ad37b914 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -1768,7 +1768,7 @@ func Test_editRun(t *testing.T) { } attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - tt.input.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(tt.attach)) + tt.input.AttachEvent = attachments.BeginTestTelemetry(t, attachmentRecorder, len(tt.attach)) } hostTokens := tt.hostTokens diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go index 2a2e967a10c..cda17d6b25b 100644 --- a/pkg/cmd/pr/comment/comment.go +++ b/pkg/cmd/pr/comment/comment.go @@ -55,7 +55,10 @@ func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, # Attach multiple files by repeating the flag $ gh pr comment 13 --attach ./before.png --attach ./after.png `), - Args: cobra.MaximumNArgs(1), + Args: func(cmd *cobra.Command, args []string) error { + opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + return cobra.MaximumNArgs(1)(cmd, args) + }, PreRunE: func(cmd *cobra.Command, args []string) error { if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { return cmdutil.FlagErrorf("argument required when using the --repo flag") @@ -113,8 +116,6 @@ func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, cmd.Flags().BoolVar(&opts.DeleteLastConfirmed, "yes", false, "Skip the delete confirmation prompt when --delete-last is provided") cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last") opts.AttachFlag = attachments.AddFlag(cmd) - opts.AttachTelemetry = attachments.NewInvocationTelemetry(opts.AttachFlag, telemetry) - cmd.Args = opts.AttachTelemetry.WrapArgs(cmd.Args) return cmd } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 0d3da98a2de..e51b2c8059a 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -77,9 +77,9 @@ type CreateOptions struct { DryRun bool - AttachFlag *attachments.Flag - AttachTelemetry *attachments.InvocationTelemetry - Assets []attachments.UserAsset + AttachFlag *attachments.Flag + AttachEvent ghtelemetry.PendingEvent + Assets []attachments.UserAsset } // creationRefs is an interface that provides the necessary information for creating a pull request in the API. @@ -273,7 +273,10 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, $ gh pr create --attach './login.png#The login error state' $ gh pr create --attach ./before.png --attach ./after.png `), - Args: cmdutil.NoArgsQuoteReminder, + Args: func(cmd *cobra.Command, args []string) error { + opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + return cmdutil.NoArgsQuoteReminder(cmd, args) + }, Aliases: []string{"new"}, RunE: func(cmd *cobra.Command, args []string) error { opts.Finder = shared.NewFinder(f) @@ -403,8 +406,6 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, fl.StringVarP(&opts.Template, "template", "T", "", "Template `file` to use as starting body text") fl.BoolVar(&opts.DryRun, "dry-run", false, "Print details instead of creating the PR. May still push git changes.") opts.AttachFlag = attachments.AddFlag(cmd) - opts.AttachTelemetry = attachments.NewInvocationTelemetry(opts.AttachFlag, telemetry) - cmd.Args = opts.AttachTelemetry.WrapArgs(cmd.Args) _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head") @@ -1129,7 +1130,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS var uploadErr error if uploader != nil { body, uploadResult, err := uploader.UploadAndAttach(context.Background(), state.Body, opts.Assets) - opts.AttachTelemetry.RecordOperations(uploadResult) + attachments.RecordOperations(opts.AttachEvent, uploadResult) // With nothing uploaded, a body that lost the files it was written // around is not what the caller asked to create. The branch is already // pushed by now, so the message says what was not created rather than diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 2343dfe30b2..b51e4bfe249 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -2062,7 +2062,7 @@ func Test_createRun(t *testing.T) { defer cleanSetup() attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - opts.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(opts.Assets)) + opts.AttachEvent = attachments.BeginTestTelemetry(t, attachmentRecorder, len(opts.Assets)) } // All tests in this function use github.com behavior diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 202d0e02815..979a072cd02 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -40,10 +40,10 @@ type EditOptions struct { SelectorArg string Interactive bool - AttachFlag *attachments.Flag - AttachTelemetry *attachments.InvocationTelemetry - Assets []attachments.UserAsset - Config func() (gh.Config, error) + AttachFlag *attachments.Flag + AttachEvent ghtelemetry.PendingEvent + Assets []attachments.UserAsset + Config func() (gh.Config, error) shared.Editable } @@ -134,7 +134,10 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, ru $ gh pr edit 23 --milestone "Version 1" $ gh pr edit 23 --remove-milestone `), - Args: cobra.MaximumNArgs(1), + Args: func(cmd *cobra.Command, args []string) error { + opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + return cobra.MaximumNArgs(1)(cmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { opts.Finder = shared.NewFinder(f) @@ -257,8 +260,6 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, ru cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the pull request belongs to by `name`") cmd.Flags().BoolVar(&removeMilestone, "remove-milestone", false, "Remove the milestone association from the pull request") opts.AttachFlag = attachments.AddFlag(cmd) - opts.AttachTelemetry = attachments.NewInvocationTelemetry(opts.AttachFlag, telemetry) - cmd.Args = opts.AttachTelemetry.WrapArgs(cmd.Args) _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base") @@ -426,7 +427,7 @@ func editRun(opts *EditOptions) error { // Nothing that can prompt or cancel may follow this. var uploadResult attachments.UploadResult body, uploadResult, uploadErr = uploader.UploadAndAttach(context.Background(), body, opts.Assets) - opts.AttachTelemetry.RecordOperations(uploadResult) + attachments.RecordOperations(opts.AttachEvent, uploadResult) // With nothing uploaded, even a body the caller typed goes unwritten: // its references are still local paths, which render broken. The other diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index b67038bf80b..a0f5010f7bf 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -1695,7 +1695,7 @@ func Test_editRun(t *testing.T) { } attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - tt.input.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(tt.attach)) + tt.input.AttachEvent = attachments.BeginTestTelemetry(t, attachmentRecorder, len(tt.attach)) } // The host comes from the pull request the row's finder returns, so diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go index bd1e9a18d43..7abf74884e9 100644 --- a/pkg/cmd/pr/shared/commentable.go +++ b/pkg/cmd/pr/shared/commentable.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/attachments" "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/text" "github.com/cli/cli/v2/pkg/cmdutil" @@ -61,7 +62,7 @@ type CommentableOptions struct { BodyProvided bool KeepExistingBody bool AttachFlag *attachments.Flag - AttachTelemetry *attachments.InvocationTelemetry + AttachEvent ghtelemetry.PendingEvent Assets []attachments.UserAsset Config func() (gh.Config, error) } @@ -355,7 +356,7 @@ func bodyForWrite(opts *CommentableOptions, uploader *attachments.Uploader) (bod return opts.Body, true, nil } body, uploadResult, err := uploader.UploadAndAttach(context.Background(), opts.Body, opts.Assets) - opts.AttachTelemetry.RecordOperations(uploadResult) + attachments.RecordOperations(opts.AttachEvent, uploadResult) if err != nil && uploadResult.Uploaded == 0 { return "", false, err } diff --git a/pkg/cmd/pr/shared/commentable_test.go b/pkg/cmd/pr/shared/commentable_test.go index 5d231bbfb96..a47797ccf14 100644 --- a/pkg/cmd/pr/shared/commentable_test.go +++ b/pkg/cmd/pr/shared/commentable_test.go @@ -453,7 +453,7 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { } attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - opts.AttachTelemetry = attachments.NewTestInvocationTelemetry(t, attachmentRecorder, len(tt.attach)) + opts.AttachEvent = attachments.BeginTestTelemetry(t, attachmentRecorder, len(tt.attach)) } host := tt.host From 14fc1ff8426d9105810ddb4af40f983de0ef7438 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 8 Sep 2026 18:10:51 +0200 Subject: [PATCH 11/22] minimize incidental telemetry service changes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9452b3e7-02d8-4d4d-b2db-ab18b35657c1 --- internal/telemetry/telemetry.go | 190 ++++++++++++++------------- internal/telemetry/telemetry_test.go | 18 +-- 2 files changed, 107 insertions(+), 101 deletions(-) diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index ec3314e594c..539bf2f4752 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -1,5 +1,4 @@ // Package telemetry provides best-effort usage telemetry for gh commands. -// Invocations collect facts until completion, then send completed payloads. package telemetry import ( @@ -145,25 +144,26 @@ func ParseTelemetryState(configValue string) TelemetryState { return Enabled } -type serviceOptions struct { +type telemetryServiceOpts struct { additionalDimensions ghtelemetry.Dimensions sampleRate int } -type serviceOption func(*serviceOptions) +type telemetryServiceOption func(*telemetryServiceOpts) -// WithAdditionalCommonDimensions sets dimensions shared by every invocation event. -func WithAdditionalCommonDimensions(dimensions ghtelemetry.Dimensions) serviceOption { - return func(options *serviceOptions) { - maps.Copy(options.additionalDimensions, dimensions) +// 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 selects invocation-wide sampling. Rates 0 and 100 retain all -// events; rates between them select a percentage using the invocation ID. -func WithSampleRate(rate int) serviceOption { - return func(options *serviceOptions) { - options.sampleRate = rate +// WithSampleRate allows setting a sample rate (0-100) for telemetry events. Rates 0 and 100 retain all events. +// 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 } } @@ -209,70 +209,65 @@ var GitHubFlusher = func(executable string) func(payload SendTelemetryPayload) { } } -// NewService creates a telemetry service using send to deliver its completed payload. -func NewService(send func(SendTelemetryPayload), opts ...serviceOption) *Service { - options := serviceOptions{ +// 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(&options) + opt(&telemetryServiceOpts) } deviceID, err := deviceIDFunc() if err != nil { deviceID = "" } + invocationID := uuid.NewString() - commonDimensions := ghtelemetry.Dimensions{ + + var commonDimensions = ghtelemetry.Dimensions{ "device_id": deviceID, "invocation_id": invocationID, "os": runtime.GOOS, "architecture": runtime.GOARCH, } - maps.Copy(commonDimensions, options.additionalDimensions) + maps.Copy(commonDimensions, telemetryServiceOpts.additionalDimensions) hash := uuid.NewSHA1(uuid.Nil, []byte(invocationID)) sampleBucket := byte(binary.BigEndian.Uint32(hash[:4]) % 100) - return &Service{ - send: send, + s := &service{ + flush: flusher, commonDimensions: commonDimensions, - sampleRate: options.sampleRate, + sampleRate: telemetryServiceOpts.sampleRate, sampleBucket: sampleBucket, } + + return s } -type invocationEvent struct { +type recordedEvent struct { event ghtelemetry.Event recordedAt time.Time } -var ( - _ ghtelemetry.Service = (*Service)(nil) - _ ghtelemetry.Service = (*NoOpService)(nil) -) +type service struct { + mu sync.RWMutex + flush func(payload SendTelemetryPayload) + previouslyCalled bool -// Service records telemetry facts and reporting policy for one command execution. -// Finish must run after command execution to send the completed payload. -type Service struct { - mu sync.Mutex - send func(SendTelemetryPayload) commonDimensions ghtelemetry.Dimensions sampleRate int sampleBucket byte - events []*invocationEvent - disabled bool - finished bool -} -type pendingEvent struct { - service *Service - recorded *invocationEvent + events []*recordedEvent + + disabled bool } // Disable suppresses all events in the invocation, including already recorded // events. It must be called before Finish. -func (s *Service) Disable() { +func (s *service) Disable() { s.mu.Lock() defer s.mu.Unlock() @@ -281,29 +276,26 @@ func (s *Service) Disable() { // Record copies a complete event into the service. // Recording after Finish has no effect. -func (s *Service) Record(event ghtelemetry.Event) { +func (s *service) Record(event ghtelemetry.Event) { s.mu.Lock() defer s.mu.Unlock() - if s.finished { + if s.previouslyCalled { return } - s.events = append(s.events, &invocationEvent{ - event: cloneEvent(event), - recordedAt: time.Now(), - }) + s.events = append(s.events, &recordedEvent{event: cloneEvent(event), recordedAt: time.Now()}) } // Begin copies an event's initial facts and returns a handle for adding // facts until Finish. Events begun after Finish are not recorded. -func (s *Service) Begin(event ghtelemetry.Event) ghtelemetry.PendingEvent { +func (s *service) Begin(event ghtelemetry.Event) ghtelemetry.PendingEvent { s.mu.Lock() defer s.mu.Unlock() - if s.finished { + if s.previouslyCalled { return noOpPendingEvent{} } - recorded := &invocationEvent{ + recorded := &recordedEvent{ event: cloneEvent(event), recordedAt: time.Now(), } @@ -311,39 +303,13 @@ func (s *Service) Begin(event ghtelemetry.Event) ghtelemetry.PendingEvent { return &pendingEvent{service: s, recorded: recorded} } -func (p *pendingEvent) UpsertDimensions(dimensions ghtelemetry.Dimensions) { - p.service.mu.Lock() - defer p.service.mu.Unlock() - - if p.service.finished { - return - } - if p.recorded.event.Dimensions == nil { - p.recorded.event.Dimensions = make(ghtelemetry.Dimensions) - } - maps.Copy(p.recorded.event.Dimensions, dimensions) -} - -func (p *pendingEvent) UpsertMeasures(measures ghtelemetry.Measures) { - p.service.mu.Lock() - defer p.service.mu.Unlock() - - if p.service.finished { - return - } - if p.recorded.event.Measures == nil { - p.recorded.event.Measures = make(ghtelemetry.Measures) - } - maps.Copy(p.recorded.event.Measures, measures) -} - // SetSampleRate selects the sampling policy for the whole invocation. // Changes after Finish have no effect. -func (s *Service) SetSampleRate(rate int) { +func (s *service) SetSampleRate(rate int) { s.mu.Lock() defer s.mu.Unlock() - if s.finished { + if s.previouslyCalled { return } s.sampleRate = rate @@ -352,42 +318,82 @@ func (s *Service) SetSampleRate(rate int) { // Finish snapshots the recorded events once and sends their payload after releasing the lock. // Sampling and telemetry eligibility apply to immediate and pending events alike. -func (s *Service) Finish() { +func (s *service) Finish() { s.mu.Lock() - if s.finished { + if s.previouslyCalled { s.mu.Unlock() return } - s.finished = true + s.previouslyCalled = true if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate { s.mu.Unlock() 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 } - // Keep an empty payload so log mode can explain that no telemetry will be sent. - payload := SendTelemetryPayload{Events: make([]PayloadEvent, len(events))} - for index, recorded := range events { + payload := SendTelemetryPayload{ + Events: make([]PayloadEvent, len(events)), + } + + for i, recorded := range events { + event := recorded.event + 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[index] = PayloadEvent{ - Type: recorded.event.Type, + maps.Copy(dimensions, event.Dimensions) + + payload.Events[i] = PayloadEvent{ + Type: event.Type, Dimensions: dimensions, - Measures: maps.Clone(recorded.event.Measures), + Measures: maps.Clone(event.Measures), } } s.mu.Unlock() - s.send(payload) + s.flush(payload) +} + +type pendingEvent struct { + service *service + recorded *recordedEvent +} + +func (p *pendingEvent) UpsertDimensions(dimensions ghtelemetry.Dimensions) { + p.service.mu.Lock() + defer p.service.mu.Unlock() + + if p.service.previouslyCalled { + return + } + if p.recorded.event.Dimensions == nil { + p.recorded.event.Dimensions = make(ghtelemetry.Dimensions) + } + maps.Copy(p.recorded.event.Dimensions, dimensions) +} + +func (p *pendingEvent) UpsertMeasures(measures ghtelemetry.Measures) { + p.service.mu.Lock() + defer p.service.mu.Unlock() + + if p.service.previouslyCalled { + return + } + if p.recorded.event.Measures == nil { + p.recorded.event.Measures = make(ghtelemetry.Measures) + } + maps.Copy(p.recorded.event.Measures, measures) } func cloneEvent(event ghtelemetry.Event) ghtelemetry.Event { @@ -490,18 +496,18 @@ func (noOpPendingEvent) UpsertMeasures(ghtelemetry.Measures) {} type NoOpService struct{} // Record discards the event. -func (*NoOpService) Record(ghtelemetry.Event) {} +func (s *NoOpService) Record(event ghtelemetry.Event) {} // Begin returns an inert handle without retaining the event. -func (*NoOpService) Begin(ghtelemetry.Event) ghtelemetry.PendingEvent { +func (s *NoOpService) Begin(event ghtelemetry.Event) ghtelemetry.PendingEvent { return noOpPendingEvent{} } // Disable leaves telemetry disabled. -func (*NoOpService) Disable() {} +func (s *NoOpService) Disable() {} // SetSampleRate leaves telemetry disabled. -func (*NoOpService) SetSampleRate(int) {} +func (s *NoOpService) SetSampleRate(rate int) {} // Finish has no payload to complete. -func (*NoOpService) Finish() {} +func (s *NoOpService) Finish() {} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 0891320b069..bcfe8f59ca4 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -565,13 +565,13 @@ func TestServiceSampling(t *testing.T) { // Given a configured sample rate and a deterministic sampling bucket t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }, WithSampleRate(tt.sampleRate)) + svc := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }, WithSampleRate(tt.sampleRate)) // Fix the random bucket so sampling boundaries can be asserted through delivery. - service.sampleBucket = tt.sampleBucket + svc.(*service).sampleBucket = tt.sampleBucket // When the invocation finishes - service.Record(ghtelemetry.Event{Type: "test"}) - service.Finish() + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Finish() // Then only invocations selected by sampling are delivered require.Len(t, payloads, tt.wantPayloads) @@ -588,13 +588,13 @@ func TestServiceSetSampleRate(t *testing.T) { // Given an invocation that initially sends all events t.Cleanup(stubDeviceID("test-device")) var payloads []SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }, WithSampleRate(0)) - service.sampleBucket = 50 + svc := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }, WithSampleRate(0)) + svc.(*service).sampleBucket = 50 // When its sample rate excludes the bucket before completion - service.SetSampleRate(10) - service.Record(ghtelemetry.Event{Type: "test"}) - service.Finish() + svc.SetSampleRate(10) + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Finish() // Then no payload is delivered assert.Empty(t, payloads) From d2fe6e98dca4e624a69bbca39bb12b0ca38206b0 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 8 Sep 2026 20:21:12 +0200 Subject: [PATCH 12/22] start attachment telemetry before asset validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9452b3e7-02d8-4d4d-b2db-ab18b35657c1 --- internal/attachments/telemetry.go | 3 +- pkg/cmd/issue/comment/comment.go | 7 +- pkg/cmd/issue/comment/comment_test.go | 131 +++++++++++++++++--------- pkg/cmd/issue/create/create.go | 6 +- pkg/cmd/issue/create/create_test.go | 37 ++++---- pkg/cmd/issue/edit/edit.go | 9 +- pkg/cmd/issue/edit/edit_test.go | 55 ++++++----- pkg/cmd/pr/comment/comment.go | 7 +- pkg/cmd/pr/comment/comment_test.go | 73 ++++++++------ pkg/cmd/pr/create/create.go | 6 +- pkg/cmd/pr/create/create_test.go | 37 ++++---- pkg/cmd/pr/edit/edit.go | 10 +- pkg/cmd/pr/edit/edit_test.go | 54 ++++++----- pkg/cmd/pr/shared/commentable.go | 7 +- pkg/cmd/pr/shared/commentable_test.go | 5 +- 15 files changed, 261 insertions(+), 186 deletions(-) diff --git a/internal/attachments/telemetry.go b/internal/attachments/telemetry.go index c72de2321b1..c1b5e08f724 100644 --- a/internal/attachments/telemetry.go +++ b/internal/attachments/telemetry.go @@ -2,7 +2,8 @@ package attachments import "github.com/cli/cli/v2/internal/gh/ghtelemetry" -// BeginTelemetry records attachment usage before validation and promotes full sampling. +// BeginTelemetry records attachment usage and promotes full sampling. +// Call it immediately before Flag.UserAssets so rejected attachments are counted. // It returns nil if the flag was not passed or the flag or recorder is absent. func BeginTelemetry(flag *Flag, recorder ghtelemetry.InvocationRecorder, command string) ghtelemetry.PendingEvent { if recorder == nil || flag == nil || !flag.Changed() { diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index 868da8120cf..54a97db0da8 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -57,10 +57,7 @@ func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, # Attach multiple files by repeating the flag $ gh issue comment 12 --attach ./before.png --attach ./after.png `), - Args: func(cmd *cobra.Command, args []string) error { - opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) - return cobra.ExactArgs(1)(cmd, args) - }, + Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { opts.RetrieveCommentable = func() (prShared.Commentable, ghrepo.Interface, error) { // TODO wm: more testing @@ -102,7 +99,7 @@ func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, return issue, baseRepo, nil } - if err := prShared.CommentablePreRun(cmd, opts); err != nil { + if err := prShared.CommentablePreRun(cmd, opts, telemetry); err != nil { return err } diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index 693d3dd01c8..6668724dcfa 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -35,6 +35,14 @@ func TestNewCmdComment(t *testing.T) { tmpImage := filepath.Join(t.TempDir(), "login.png") require.NoError(t, os.WriteFile(tmpImage, []byte("the bytes"), 0600)) + attachmentEvent := []ghtelemetry.Event{{ + Type: "attachment_invocation", + Dimensions: ghtelemetry.Dimensions{"command": "comment"}, + Measures: ghtelemetry.Measures{ + "attach_count": 1, "append_ops_count": 0, "replace_ops_count": 0, + }, + }} + tests := []struct { name string input string @@ -43,6 +51,8 @@ func TestNewCmdComment(t *testing.T) { wantsErr bool wantsErrContains string isTTY bool + wantEvents []ghtelemetry.Event + wantSampleRate int // Which fields the lookup asks for is decided at construction, so it // can only be proved here. @@ -266,8 +276,10 @@ func TestNewCmdComment(t *testing.T) { wantsErr: true, }, { - name: "--attach alone is enough of a body to post without prompting", - input: fmt.Sprintf("1 --attach '%s'", tmpImage), + name: "--attach alone is enough of a body to post without prompting", + input: fmt.Sprintf("1 --attach '%s'", tmpImage), + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, output: shared.CommentableOptions{ Interactive: false, InputType: shared.InputTypeInline, @@ -277,14 +289,16 @@ func TestNewCmdComment(t *testing.T) { wantsErr: false, }, { - name: "--attach telemetry survives argument validation", + name: "argument validation skips attachment telemetry", input: fmt.Sprintf("--attach '%s'", tmpImage), isTTY: false, wantsErr: true, }, { - name: "--attach with --body keeps the body and records that one was given", - input: fmt.Sprintf("1 --body test --attach '%s'", tmpImage), + name: "--attach with --body keeps the body and records that one was given", + input: fmt.Sprintf("1 --body test --attach '%s'", tmpImage), + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, output: shared.CommentableOptions{ Interactive: false, InputType: shared.InputTypeInline, @@ -295,8 +309,10 @@ func TestNewCmdComment(t *testing.T) { wantsErr: false, }, { - name: "--attach with --edit-last is accepted and needs no body", - input: fmt.Sprintf("1 --edit-last --attach '%s'", tmpImage), + name: "--attach with --edit-last is accepted and needs no body", + input: fmt.Sprintf("1 --edit-last --attach '%s'", tmpImage), + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, output: shared.CommentableOptions{ Interactive: false, InputType: shared.InputTypeInline, @@ -308,8 +324,10 @@ func TestNewCmdComment(t *testing.T) { { // KeepExistingBody is set either way. BodyProvided is what decides // that this one replaces the comment rather than keeping it. - name: "--attach with --edit-last and --body records the body that replaces the comment", - input: fmt.Sprintf("1 --edit-last --body 'a new body' --attach '%s'", tmpImage), + name: "--attach with --edit-last and --body records the body that replaces the comment", + input: fmt.Sprintf("1 --edit-last --body 'a new body' --attach '%s'", tmpImage), + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, output: shared.CommentableOptions{ Interactive: false, InputType: shared.InputTypeInline, @@ -340,10 +358,14 @@ func TestNewCmdComment(t *testing.T) { isTTY: true, wantsErr: true, wantsErrContains: "./nope.png: ", + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, }, { - name: "--attach asks the issue lookup for the repository id", - input: fmt.Sprintf("1 --attach '%s'", tmpImage), + name: "--attach asks the issue lookup for the repository id", + input: fmt.Sprintf("1 --attach '%s'", tmpImage), + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, output: shared.CommentableOptions{ Interactive: false, InputType: shared.InputTypeInline, @@ -368,6 +390,7 @@ func TestNewCmdComment(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Given command inputs and their expected attachment telemetry ios, stdin, _, _ := iostreams.Test() isTTY := tt.isTTY ios.SetStdoutTTY(isTTY) @@ -414,7 +437,7 @@ func TestNewCmdComment(t *testing.T) { } argv, err := shlex.Split(tt.input) - assert.NoError(t, err) + require.NoError(t, err) var gotOpts *shared.CommentableOptions recorder := &telemetry.InvocationRecorderSpy{} @@ -429,33 +452,21 @@ func TestNewCmdComment(t *testing.T) { cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) + // When the command executes and telemetry is completed _, err = cmd.ExecuteC() recorder.Finish() - if cmd.Flags().Changed("attach") { - values, flagErr := cmd.Flags().GetStringArray("attach") - require.NoError(t, flagErr) - require.Equal(t, ghtelemetry.SAMPLE_ALL, recorder.LastSampleRate) - require.Len(t, recorder.Events, 1) - assert.Equal(t, "attachment_invocation", recorder.Events[0].Type) - assert.Equal(t, cmd.CommandPath(), recorder.Events[0].Dimensions["command"]) - assert.Equal(t, ghtelemetry.Measures{ - "attach_count": int64(len(values)), - "append_ops_count": 0, - "replace_ops_count": 0, - }, recorder.Events[0].Measures) - } else { - assert.Empty(t, recorder.Events) - assert.Zero(t, recorder.LastSampleRate) - } + // Then telemetry starts only if execution reaches attachment validation + assert.Equal(t, tt.wantEvents, recorder.Events) + assert.Equal(t, tt.wantSampleRate, recorder.LastSampleRate) if tt.wantsErr { - assert.Error(t, err) + require.Error(t, err) if tt.wantsErrContains != "" { - assert.ErrorContains(t, err, tt.wantsErrContains) + require.ErrorContains(t, err, tt.wantsErrContains) } return } - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, tt.output.Interactive, gotOpts.Interactive) assert.Equal(t, tt.output.InputType, gotOpts.InputType) assert.Equal(t, tt.output.Body, gotOpts.Body) @@ -481,7 +492,49 @@ func TestNewCmdComment(t *testing.T) { } } -func TestNewCmdCommentRecordsAttachmentsBeforePersistentPreRunError(t *testing.T) { +func TestNewCmdCommentRecordsRejectedAttachmentCount(t *testing.T) { + // Given 51 attachment values and a normally sampled telemetry service + t.Setenv("XDG_STATE_HOME", t.TempDir()) + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Browser: &browser.Stub{}, + Config: testConfig(), + } + var payload telemetry.SendTelemetryPayload + service := telemetry.NewService(func(p telemetry.SendTelemetryPayload) { + payload = p + }, telemetry.WithSampleRate(1)) + cmd := NewCmdComment(f, service, func(*shared.CommentableOptions) error { + return errors.New("run should not be called") + }) + args := []string{"1"} + for i := range 51 { + args = append(args, "--attach", fmt.Sprintf("./shot-%d.png", i)) + } + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + // When attachment validation rejects the limit and telemetry is completed + err := cmd.Execute() + service.Finish() + + // Then the payload preserves the rejected count without any completed operations + require.EqualError(t, err, "`--attach` accepts at most 50 values per command") + require.Len(t, payload.Events, 1) + event := payload.Events[0] + assert.Equal(t, "attachment_invocation", event.Type) + assert.Equal(t, "comment", event.Dimensions["command"]) + assert.Equal(t, "100", event.Dimensions["sample_rate"]) + assert.Equal(t, map[string]int64{ + "attach_count": 51, + "append_ops_count": 0, + "replace_ops_count": 0, + }, event.Measures) +} + +func TestNewCmdCommentSkipsAttachmentsOnPersistentPreRunError(t *testing.T) { t.Parallel() // Given an attachment command whose parent rejects execution before PreRunE @@ -511,17 +564,9 @@ func TestNewCmdCommentRecordsAttachmentsBeforePersistentPreRunError(t *testing.T require.EqualError(t, err, "authentication failed") recorder.Finish() - // Then the attempted attachment is retained without completed operations - assert.Equal(t, ghtelemetry.SAMPLE_ALL, recorder.LastSampleRate) - assert.Equal(t, []ghtelemetry.Event{{ - Type: "attachment_invocation", - Dimensions: ghtelemetry.Dimensions{"command": "gh comment"}, - Measures: ghtelemetry.Measures{ - "attach_count": 1, - "append_ops_count": 0, - "replace_ops_count": 0, - }, - }}, recorder.Events) + // Then attachment validation has not begun, so no event or sampling promotion occurs + assert.Empty(t, recorder.Events) + assert.Zero(t, recorder.LastSampleRate) } func Test_commentRun(t *testing.T) { diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 0d2300303ab..e7fbb48f3f9 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -119,10 +119,7 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, $ gh issue create --parent https://github.com/cli/go-gh/issues/42 $ gh issue create --blocked-by 200,201 --blocking 300 `), - Args: func(cmd *cobra.Command, args []string) error { - opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) - return cmdutil.NoArgsQuoteReminder(cmd, args) - }, + Args: cmdutil.NoArgsQuoteReminder, Aliases: []string{"new"}, RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override @@ -168,6 +165,7 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, return err } + opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index b6294981bf6..a0cbf7efd92 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -41,6 +41,14 @@ func TestNewCmdCreate(t *testing.T) { tmpImage := filepath.Join(t.TempDir(), "shot.png") require.NoError(t, os.WriteFile(tmpImage, []byte("the bytes"), 0600)) + attachmentEvent := []ghtelemetry.Event{{ + Type: "attachment_invocation", + Dimensions: ghtelemetry.Dimensions{"command": "create"}, + Measures: ghtelemetry.Measures{ + "attach_count": 1, "append_ops_count": 0, "replace_ops_count": 0, + }, + }} + tests := []struct { name string tty bool @@ -54,6 +62,8 @@ func TestNewCmdCreate(t *testing.T) { wantErrIsNotExist bool wantsOpts CreateOptions wantAssetPaths []string + wantEvents []ghtelemetry.Event + wantSampleRate int }{ { name: "empty non-tty", @@ -275,9 +285,11 @@ func TestNewCmdCreate(t *testing.T) { Body: "mybody", }, wantAssetPaths: []string{tmpImage}, + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, }, { - name: "attach telemetry survives argument validation", + name: "argument validation skips attachment telemetry", tty: false, cli: fmt.Sprintf(`unexpected --attach '%s'`, tmpImage), wantsErr: true, @@ -296,10 +308,13 @@ func TestNewCmdCreate(t *testing.T) { wantsErr: true, wantsErrMsg: "./nope.png: ", wantErrIsNotExist: true, + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Given command inputs and their expected attachment telemetry ios, stdin, stdout, stderr := iostreams.Test() if tt.stdin != "" { _, _ = stdin.WriteString(tt.stdin) @@ -330,24 +345,12 @@ func TestNewCmdCreate(t *testing.T) { cmd.SetArgs(args) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) + // When the command executes and telemetry is completed _, err = cmd.ExecuteC() recorder.Finish() - if cmd.Flags().Changed("attach") { - values, flagErr := cmd.Flags().GetStringArray("attach") - require.NoError(t, flagErr) - require.Equal(t, ghtelemetry.SAMPLE_ALL, recorder.LastSampleRate) - require.Len(t, recorder.Events, 1) - assert.Equal(t, "attachment_invocation", recorder.Events[0].Type) - assert.Equal(t, cmd.CommandPath(), recorder.Events[0].Dimensions["command"]) - assert.Equal(t, ghtelemetry.Measures{ - "attach_count": int64(len(values)), - "append_ops_count": 0, - "replace_ops_count": 0, - }, recorder.Events[0].Measures) - } else { - assert.Empty(t, recorder.Events) - assert.Zero(t, recorder.LastSampleRate) - } + // Then telemetry starts only if execution reaches attachment validation + assert.Equal(t, tt.wantEvents, recorder.Events) + assert.Equal(t, tt.wantSampleRate, recorder.LastSampleRate) if tt.wantsErr { require.Error(t, err) if tt.wantsErrMsg != "" { diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 7bb4e23d4b1..e7ceeb25701 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -122,10 +122,7 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, ru $ gh issue edit 100 --add-sub-issue 123,124 $ gh issue edit 123 --add-blocked-by 200 --add-blocking 300,301 `), - Args: func(cmd *cobra.Command, args []string) error { - opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) - return cobra.MinimumNArgs(1)(cmd, args) - }, + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { issueNumbers, baseRepo, err := issueShared.ParseIssuesFromArgs(args) if err != nil { @@ -215,11 +212,11 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, ru opts.Editable.IssueType.Edited = true } - resolved, err := opts.AttachFlag.UserAssets() + opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err } - opts.Assets = resolved // An empty --parent resolves to no work, so it counts as an edit // only here, where passing the flag at all suppresses the survey. diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index ae4ad37b914..f1276319aa5 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -41,6 +41,14 @@ func TestNewCmdEdit(t *testing.T) { tmpImage := filepath.Join(t.TempDir(), "shot.png") require.NoError(t, os.WriteFile(tmpImage, []byte("the bytes"), 0600)) + attachmentEvent := []ghtelemetry.Event{{ + Type: "attachment_invocation", + Dimensions: ghtelemetry.Dimensions{"command": "edit"}, + Measures: ghtelemetry.Measures{ + "attach_count": 1, "append_ops_count": 0, "replace_ops_count": 0, + }, + }} + tests := []struct { name string input string @@ -53,6 +61,8 @@ func TestNewCmdEdit(t *testing.T) { // wantErrIsNotExist covers an error whose text the operating system // words differently, so the assertion cannot be on the message. wantErrIsNotExist bool + wantEvents []ghtelemetry.Event + wantSampleRate int }{ { name: "no argument", @@ -408,9 +418,11 @@ func TestNewCmdEdit(t *testing.T) { Interactive: false, }, wantAssetPaths: []string{tmpImage}, + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, }, { - name: "attach telemetry survives argument validation", + name: "argument validation skips attachment telemetry", input: fmt.Sprintf("--attach '%s'", tmpImage), wantsErr: true, wantsErrMsg: "requires at least 1 arg(s), only received 0", @@ -429,12 +441,22 @@ func TestNewCmdEdit(t *testing.T) { }, }, wantAssetPaths: []string{tmpImage}, + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, + }, + { + name: "attach flag with more than one issue", + input: fmt.Sprintf("23 34 --attach '%s'", tmpImage), + wantsErr: true, + wantsErrMsg: "`--attach` cannot be used when editing multiple issues", + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, }, { - name: "attach flag with more than one issue", - input: fmt.Sprintf("23 34 --attach '%s'", tmpImage), + name: "body flag conflict skips attachment telemetry", + input: fmt.Sprintf("23 --body test --body-file '%s' --attach '%s'", tmpFile, tmpImage), wantsErr: true, - wantsErrMsg: "`--attach` cannot be used when editing multiple issues", + wantsErrMsg: "specify only one of `--body` or `--body-file`", }, { name: "attach flag naming a file that does not exist", @@ -442,10 +464,13 @@ func TestNewCmdEdit(t *testing.T) { wantsErr: true, wantsErrMsg: "./nope.png: ", wantErrIsNotExist: true, + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Given command inputs and their expected attachment telemetry ios, stdin, _, _ := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStdinTTY(true) @@ -460,7 +485,7 @@ func TestNewCmdEdit(t *testing.T) { } argv, err := shlex.Split(tt.input) - assert.NoError(t, err) + require.NoError(t, err) var gotOpts *EditOptions recorder := &telemetry.InvocationRecorderSpy{} @@ -475,24 +500,12 @@ func TestNewCmdEdit(t *testing.T) { cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) + // When the command executes and telemetry is completed _, err = cmd.ExecuteC() recorder.Finish() - if cmd.Flags().Changed("attach") { - values, flagErr := cmd.Flags().GetStringArray("attach") - require.NoError(t, flagErr) - require.Equal(t, ghtelemetry.SAMPLE_ALL, recorder.LastSampleRate) - require.Len(t, recorder.Events, 1) - assert.Equal(t, "attachment_invocation", recorder.Events[0].Type) - assert.Equal(t, cmd.CommandPath(), recorder.Events[0].Dimensions["command"]) - assert.Equal(t, ghtelemetry.Measures{ - "attach_count": int64(len(values)), - "append_ops_count": 0, - "replace_ops_count": 0, - }, recorder.Events[0].Measures) - } else { - assert.Empty(t, recorder.Events) - assert.Zero(t, recorder.LastSampleRate) - } + // Then telemetry starts only if execution reaches attachment validation + assert.Equal(t, tt.wantEvents, recorder.Events) + assert.Equal(t, tt.wantSampleRate, recorder.LastSampleRate) if tt.wantsErr { require.Error(t, err) if tt.wantsErrMsg != "" { diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go index cda17d6b25b..d68ab287a0e 100644 --- a/pkg/cmd/pr/comment/comment.go +++ b/pkg/cmd/pr/comment/comment.go @@ -55,10 +55,7 @@ func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, # Attach multiple files by repeating the flag $ gh pr comment 13 --attach ./before.png --attach ./after.png `), - Args: func(cmd *cobra.Command, args []string) error { - opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) - return cobra.MaximumNArgs(1)(cmd, args) - }, + Args: cobra.MaximumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { return cmdutil.FlagErrorf("argument required when using the --repo flag") @@ -81,7 +78,7 @@ func NewCmdComment(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, Fields: fields, }) } - if err := shared.CommentablePreRun(cmd, opts); err != nil { + if err := shared.CommentablePreRun(cmd, opts, telemetry); err != nil { return err } diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go index 4e9607d8b78..6d1b84ecf92 100644 --- a/pkg/cmd/pr/comment/comment_test.go +++ b/pkg/cmd/pr/comment/comment_test.go @@ -34,6 +34,14 @@ func TestNewCmdComment(t *testing.T) { tmpImage := filepath.Join(t.TempDir(), "login.png") require.NoError(t, os.WriteFile(tmpImage, []byte("the bytes"), 0600)) + attachmentEvent := []ghtelemetry.Event{{ + Type: "attachment_invocation", + Dimensions: ghtelemetry.Dimensions{"command": "comment"}, + Measures: ghtelemetry.Measures{ + "attach_count": 1, "append_ops_count": 0, "replace_ops_count": 0, + }, + }} + tests := []struct { name string input string @@ -42,6 +50,8 @@ func TestNewCmdComment(t *testing.T) { wantsErr bool wantsErrContains string isTTY bool + wantEvents []ghtelemetry.Event + wantSampleRate int // Which fields the lookup asks for is decided at construction, so it // can only be proved here. @@ -287,8 +297,10 @@ func TestNewCmdComment(t *testing.T) { wantsErr: true, }, { - name: "--attach alone is enough of a body to post without prompting", - input: fmt.Sprintf("1 --attach '%s'", tmpImage), + name: "--attach alone is enough of a body to post without prompting", + input: fmt.Sprintf("1 --attach '%s'", tmpImage), + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, output: shared.CommentableOptions{ Interactive: false, InputType: shared.InputTypeInline, @@ -298,14 +310,16 @@ func TestNewCmdComment(t *testing.T) { wantsErr: false, }, { - name: "--attach telemetry survives argument validation", + name: "argument validation skips attachment telemetry", input: fmt.Sprintf("1 2 --attach '%s'", tmpImage), isTTY: false, wantsErr: true, }, { - name: "--attach with --body keeps the body and records that one was given", - input: fmt.Sprintf("1 --body test --attach '%s'", tmpImage), + name: "--attach with --body keeps the body and records that one was given", + input: fmt.Sprintf("1 --body test --attach '%s'", tmpImage), + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, output: shared.CommentableOptions{ Interactive: false, InputType: shared.InputTypeInline, @@ -316,8 +330,10 @@ func TestNewCmdComment(t *testing.T) { wantsErr: false, }, { - name: "--attach with --edit-last is accepted and needs no body", - input: fmt.Sprintf("1 --edit-last --attach '%s'", tmpImage), + name: "--attach with --edit-last is accepted and needs no body", + input: fmt.Sprintf("1 --edit-last --attach '%s'", tmpImage), + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, output: shared.CommentableOptions{ Interactive: false, InputType: shared.InputTypeInline, @@ -329,8 +345,10 @@ func TestNewCmdComment(t *testing.T) { { // KeepExistingBody is set either way. BodyProvided is what decides // that this one replaces the comment rather than keeping it. - name: "--attach with --edit-last and --body records the body that replaces the comment", - input: fmt.Sprintf("1 --edit-last --body 'a new body' --attach '%s'", tmpImage), + name: "--attach with --edit-last and --body records the body that replaces the comment", + input: fmt.Sprintf("1 --edit-last --body 'a new body' --attach '%s'", tmpImage), + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, output: shared.CommentableOptions{ Interactive: false, InputType: shared.InputTypeInline, @@ -361,10 +379,14 @@ func TestNewCmdComment(t *testing.T) { isTTY: true, wantsErr: true, wantsErrContains: "./nope.png: ", + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, }, { - name: "--attach asks the pull request lookup for the repository id", - input: fmt.Sprintf("1 --attach '%s'", tmpImage), + name: "--attach asks the pull request lookup for the repository id", + input: fmt.Sprintf("1 --attach '%s'", tmpImage), + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, output: shared.CommentableOptions{ Interactive: false, InputType: shared.InputTypeInline, @@ -389,6 +411,7 @@ func TestNewCmdComment(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Given command inputs and their expected attachment telemetry ios, stdin, _, _ := iostreams.Test() isTTY := tt.isTTY ios.SetStdoutTTY(isTTY) @@ -435,7 +458,7 @@ func TestNewCmdComment(t *testing.T) { } argv, err := shlex.Split(tt.input) - assert.NoError(t, err) + require.NoError(t, err) var gotOpts *shared.CommentableOptions recorder := &telemetry.InvocationRecorderSpy{} @@ -450,33 +473,21 @@ func TestNewCmdComment(t *testing.T) { cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) + // When the command executes and telemetry is completed _, err = cmd.ExecuteC() recorder.Finish() - if cmd.Flags().Changed("attach") { - values, flagErr := cmd.Flags().GetStringArray("attach") - require.NoError(t, flagErr) - require.Equal(t, ghtelemetry.SAMPLE_ALL, recorder.LastSampleRate) - require.Len(t, recorder.Events, 1) - assert.Equal(t, "attachment_invocation", recorder.Events[0].Type) - assert.Equal(t, cmd.CommandPath(), recorder.Events[0].Dimensions["command"]) - assert.Equal(t, ghtelemetry.Measures{ - "attach_count": int64(len(values)), - "append_ops_count": 0, - "replace_ops_count": 0, - }, recorder.Events[0].Measures) - } else { - assert.Empty(t, recorder.Events) - assert.Zero(t, recorder.LastSampleRate) - } + // Then telemetry starts only if execution reaches attachment validation + assert.Equal(t, tt.wantEvents, recorder.Events) + assert.Equal(t, tt.wantSampleRate, recorder.LastSampleRate) if tt.wantsErr { - assert.Error(t, err) + require.Error(t, err) if tt.wantsErrContains != "" { - assert.ErrorContains(t, err, tt.wantsErrContains) + require.ErrorContains(t, err, tt.wantsErrContains) } return } - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, tt.output.Interactive, gotOpts.Interactive) assert.Equal(t, tt.output.InputType, gotOpts.InputType) assert.Equal(t, tt.output.Body, gotOpts.Body) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index e51b2c8059a..decef6ff395 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -273,10 +273,7 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, $ gh pr create --attach './login.png#The login error state' $ gh pr create --attach ./before.png --attach ./after.png `), - Args: func(cmd *cobra.Command, args []string) error { - opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) - return cmdutil.NoArgsQuoteReminder(cmd, args) - }, + Args: cmdutil.NoArgsQuoteReminder, Aliases: []string{"new"}, RunE: func(cmd *cobra.Command, args []string) error { opts.Finder = shared.NewFinder(f) @@ -372,6 +369,7 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, return err } + opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index b51e4bfe249..115fb512600 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -44,6 +44,14 @@ func TestNewCmdCreate(t *testing.T) { tmpImage := filepath.Join(t.TempDir(), "shot.png") require.NoError(t, os.WriteFile(tmpImage, []byte("the bytes"), 0600)) + attachmentEvent := []ghtelemetry.Event{{ + Type: "attachment_invocation", + Dimensions: ghtelemetry.Dimensions{"command": "create"}, + Measures: ghtelemetry.Measures{ + "attach_count": 1, "append_ops_count": 0, "replace_ops_count": 0, + }, + }} + tests := []struct { name string tty bool @@ -57,6 +65,8 @@ func TestNewCmdCreate(t *testing.T) { wantErrIsNotExist bool wantAssetPaths []string wantsOpts CreateOptions + wantEvents []ghtelemetry.Event + wantSampleRate int }{ { name: "empty non-tty", @@ -295,9 +305,11 @@ func TestNewCmdCreate(t *testing.T) { MaintainerCanModify: true, }, wantAssetPaths: []string{tmpImage}, + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, }, { - name: "attach telemetry survives argument validation", + name: "argument validation skips attachment telemetry", cli: fmt.Sprintf("unexpected --attach '%s'", tmpImage), wantsErr: true, }, @@ -307,6 +319,8 @@ func TestNewCmdCreate(t *testing.T) { wantsErr: true, wantsErrMsg: "./nope.png: ", wantErrIsNotExist: true, + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, }, { name: "attach conflict is reported before a missing file", @@ -323,6 +337,7 @@ func TestNewCmdCreate(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Given command inputs and their expected attachment telemetry ios, stdin, stdout, stderr := iostreams.Test() if tt.stdin != "" { _, _ = stdin.WriteString(tt.stdin) @@ -353,24 +368,12 @@ func TestNewCmdCreate(t *testing.T) { cmd.SetArgs(args) cmd.SetOut(stderr) cmd.SetErr(stderr) + // When the command executes and telemetry is completed _, err = cmd.ExecuteC() recorder.Finish() - if cmd.Flags().Changed("attach") { - values, flagErr := cmd.Flags().GetStringArray("attach") - require.NoError(t, flagErr) - require.Equal(t, ghtelemetry.SAMPLE_ALL, recorder.LastSampleRate) - require.Len(t, recorder.Events, 1) - assert.Equal(t, "attachment_invocation", recorder.Events[0].Type) - assert.Equal(t, cmd.CommandPath(), recorder.Events[0].Dimensions["command"]) - assert.Equal(t, ghtelemetry.Measures{ - "attach_count": int64(len(values)), - "append_ops_count": 0, - "replace_ops_count": 0, - }, recorder.Events[0].Measures) - } else { - assert.Empty(t, recorder.Events) - assert.Zero(t, recorder.LastSampleRate) - } + // Then telemetry starts only if execution reaches attachment validation + assert.Equal(t, tt.wantEvents, recorder.Events) + assert.Equal(t, tt.wantSampleRate, recorder.LastSampleRate) if tt.wantsErr { if tt.wantsErrMsg != "" { if tt.wantErrIsNotExist { diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 979a072cd02..c2e07e5a29f 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -134,10 +134,7 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, ru $ gh pr edit 23 --milestone "Version 1" $ gh pr edit 23 --remove-milestone `), - Args: func(cmd *cobra.Command, args []string) error { - opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) - return cobra.MaximumNArgs(1)(cmd, args) - }, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { opts.Finder = shared.NewFinder(f) @@ -223,11 +220,12 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, ru // see the `Editable.MilestoneId` method. } - resolved, err := opts.AttachFlag.UserAssets() + var err error + opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err } - opts.Assets = resolved if !opts.Editable.Dirty() && len(opts.Assets) == 0 { opts.Interactive = true diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index a0f5010f7bf..d9ff48ae3e0 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -35,6 +35,14 @@ func TestNewCmdEdit(t *testing.T) { tmpImage := filepath.Join(t.TempDir(), "shot.png") require.NoError(t, os.WriteFile(tmpImage, []byte("the bytes"), 0600)) + attachmentEvent := []ghtelemetry.Event{{ + Type: "attachment_invocation", + Dimensions: ghtelemetry.Dimensions{"command": "edit"}, + Measures: ghtelemetry.Measures{ + "attach_count": 1, "append_ops_count": 0, "replace_ops_count": 0, + }, + }} + tests := []struct { name string input string @@ -43,6 +51,8 @@ func TestNewCmdEdit(t *testing.T) { wantAssetPaths []string expectedBaseRepo ghrepo.Interface wantsErr bool + wantEvents []ghtelemetry.Event + wantSampleRate int }{ { name: "no argument", @@ -315,9 +325,11 @@ func TestNewCmdEdit(t *testing.T) { Interactive: false, }, wantAssetPaths: []string{tmpImage}, + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, }, { - name: "attach telemetry survives argument validation", + name: "argument validation skips attachment telemetry", input: fmt.Sprintf("23 24 --attach '%s'", tmpImage), wantsErr: true, }, @@ -335,15 +347,25 @@ func TestNewCmdEdit(t *testing.T) { }, }, wantAssetPaths: []string{tmpImage}, + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, + }, + { + name: "attach rejects a missing file", + input: "23 --attach ./nope.png", + wantsErr: true, + wantEvents: attachmentEvent, + wantSampleRate: ghtelemetry.SAMPLE_ALL, }, { - name: "attach rejects a missing file", - input: "23 --attach ./nope.png", + name: "body flag conflict skips attachment telemetry", + input: fmt.Sprintf("23 --body test --body-file '%s' --attach '%s'", tmpFile, tmpImage), wantsErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Given command inputs and their expected attachment telemetry ios, stdin, _, _ := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStdinTTY(true) @@ -358,7 +380,7 @@ func TestNewCmdEdit(t *testing.T) { } argv, err := shlex.Split(tt.input) - assert.NoError(t, err) + require.NoError(t, err) var gotOpts *EditOptions recorder := &telemetry.InvocationRecorderSpy{} @@ -373,30 +395,18 @@ func TestNewCmdEdit(t *testing.T) { cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) + // When the command executes and telemetry is completed _, err = cmd.ExecuteC() recorder.Finish() - if cmd.Flags().Changed("attach") { - values, flagErr := cmd.Flags().GetStringArray("attach") - require.NoError(t, flagErr) - require.Equal(t, ghtelemetry.SAMPLE_ALL, recorder.LastSampleRate) - require.Len(t, recorder.Events, 1) - assert.Equal(t, "attachment_invocation", recorder.Events[0].Type) - assert.Equal(t, cmd.CommandPath(), recorder.Events[0].Dimensions["command"]) - assert.Equal(t, ghtelemetry.Measures{ - "attach_count": int64(len(values)), - "append_ops_count": 0, - "replace_ops_count": 0, - }, recorder.Events[0].Measures) - } else { - assert.Empty(t, recorder.Events) - assert.Zero(t, recorder.LastSampleRate) - } + // Then telemetry starts only if execution reaches attachment validation + assert.Equal(t, tt.wantEvents, recorder.Events) + assert.Equal(t, tt.wantSampleRate, recorder.LastSampleRate) if tt.wantsErr { - assert.Error(t, err) + require.Error(t, err) return } - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg) assert.Equal(t, tt.output.Interactive, gotOpts.Interactive) assert.Equal(t, tt.output.Editable, gotOpts.Editable) diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go index 7abf74884e9..70d43cb0eb3 100644 --- a/pkg/cmd/pr/shared/commentable.go +++ b/pkg/cmd/pr/shared/commentable.go @@ -67,7 +67,7 @@ type CommentableOptions struct { Config func() (gh.Config, error) } -func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error { +func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions, telemetry ghtelemetry.InvocationRecorder) error { inputFlags := 0 if cmd.Flags().Changed("body") { opts.InputType = InputTypeInline @@ -105,11 +105,12 @@ func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error { return err } - resolved, err := opts.AttachFlag.UserAssets() + var err error + opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err } - opts.Assets = resolved // An asset is a body input on its own, so `--attach shot.png` alone // posts an image with no text. It is not part of the mutually exclusive diff --git a/pkg/cmd/pr/shared/commentable_test.go b/pkg/cmd/pr/shared/commentable_test.go index a47797ccf14..1634edfbc3a 100644 --- a/pkg/cmd/pr/shared/commentable_test.go +++ b/pkg/cmd/pr/shared/commentable_test.go @@ -137,6 +137,7 @@ func TestCommentablePreRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Given comment inputs and the files they reference t.Chdir(t.TempDir()) require.NoError(t, os.WriteFile("shot.png", []byte("the bytes"), 0o600)) @@ -148,8 +149,10 @@ func TestCommentablePreRun(t *testing.T) { opts := &CommentableOptions{IO: ios} cmd := commentableCmd(t, opts, tt.input) - err := CommentablePreRun(cmd, opts) + // When comment preparation validates those inputs + err := CommentablePreRun(cmd, opts, nil) + // Then it reports the validation error or prepares the comment options if tt.wantErr != "" { if tt.wantErrIsNotExist { require.ErrorIs(t, err, fs.ErrNotExist) From 38704130e876e41a4caffb8a7d045c12f592df73 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 8 Sep 2026 20:43:34 +0200 Subject: [PATCH 13/22] use typed attachment telemetry events Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9452b3e7-02d8-4d4d-b2db-ab18b35657c1 --- internal/attachments/flags.go | 6 ++++ internal/attachments/flags_test.go | 43 ++++++++++++++++++++++++--- internal/attachments/telemetry.go | 36 ++++++++++++++-------- internal/attachments/test.go | 12 -------- pkg/cmd/issue/create/create.go | 6 ++-- pkg/cmd/issue/create/create_test.go | 5 +++- pkg/cmd/issue/edit/edit.go | 6 ++-- pkg/cmd/issue/edit/edit_test.go | 5 +++- pkg/cmd/pr/create/create.go | 6 ++-- pkg/cmd/pr/create/create_test.go | 5 +++- pkg/cmd/pr/edit/edit.go | 6 ++-- pkg/cmd/pr/edit/edit_test.go | 5 +++- pkg/cmd/pr/shared/commentable.go | 6 ++-- pkg/cmd/pr/shared/commentable_test.go | 7 +++-- 14 files changed, 105 insertions(+), 49 deletions(-) diff --git a/internal/attachments/flags.go b/internal/attachments/flags.go index c47117c79fc..f2fe9076f9b 100644 --- a/internal/attachments/flags.go +++ b/internal/attachments/flags.go @@ -39,6 +39,12 @@ func (f *Flag) Changed() bool { return f.flag.Changed } +// Count returns the number of supplied attachment values before validation. +// Absent flags count as zero; empty paths and values over the limit still count. +func (f *Flag) Count() int { + return 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) { diff --git a/internal/attachments/flags_test.go b/internal/attachments/flags_test.go index da29c86738e..eb84daa79d5 100644 --- a/internal/attachments/flags_test.go +++ b/internal/attachments/flags_test.go @@ -100,8 +100,9 @@ func TestAttachmentTelemetry(t *testing.T) { wantValidationErr string }{ { - name: "flag not passed", - input: "", + name: "flag not passed ignores operation updates", + input: "", + operations: &UploadResult{AppendOperations: 1, ReplaceOperations: 1}, }, { name: "one attachment", @@ -133,6 +134,13 @@ func TestAttachmentTelemetry(t *testing.T) { wantCount: 1, operations: &UploadResult{}, }, + { + name: "empty path still counts", + input: `--attach ""`, + wantEvent: true, + wantCount: 1, + wantValidationErr: "cannot attach an empty path; --attach needs a file path", + }, { name: "over attachment limit", input: strings.Repeat("--attach ./missing.png ", maxAttachments+1), @@ -144,13 +152,15 @@ func TestAttachmentTelemetry(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Given raw attachment inputs, including values validation will reject _, attachFlag := attachCmd(t, tt.input) recorder := &telemetry.InvocationRecorderSpy{} - event := BeginTelemetry(attachFlag, recorder, "gh issue comment") + // When telemetry begins before validation and captures operation results + event := Begin(recorder, "gh issue comment", attachFlag.Count()) assert.Empty(t, recorder.Events) if tt.operations != nil { - RecordOperations(event, *tt.operations) + event.RecordOperations(*tt.operations) } if tt.wantValidationErr != "" { _, err := attachFlag.UserAssets() @@ -159,7 +169,9 @@ func TestAttachmentTelemetry(t *testing.T) { recorder.Finish() recorder.Finish() + // Then the completed event retains raw counts, or nothing if no flag was supplied if !tt.wantEvent { + assert.Nil(t, event) assert.Empty(t, recorder.Events) assert.Zero(t, recorder.LastSampleRate) return @@ -186,6 +198,29 @@ func TestAttachmentTelemetry(t *testing.T) { } } +func TestTelemetryEventReplacesOperationCounts(t *testing.T) { + // Given an attachment event with previously recorded operation counts + t.Setenv("XDG_STATE_HOME", t.TempDir()) + var payload telemetry.SendTelemetryPayload + service := telemetry.NewService(func(p telemetry.SendTelemetryPayload) { + payload = p + }) + event := Begin(service, "gh issue comment", 2) + event.RecordOperations(UploadResult{AppendOperations: 2}) + + // When a later result replaces those counts and the invocation finishes + event.RecordOperations(UploadResult{Uploaded: 1, ReplaceOperations: 1}) + service.Finish() + + // Then the payload contains the latest counts, including a reset to zero + require.Len(t, payload.Events, 1) + assert.Equal(t, map[string]int64{ + "attach_count": 2, + "append_ops_count": 0, + "replace_ops_count": 1, + }, payload.Events[0].Measures) +} + func TestFlagUserAssets(t *testing.T) { tests := []struct { name string diff --git a/internal/attachments/telemetry.go b/internal/attachments/telemetry.go index c1b5e08f724..496b1b39f00 100644 --- a/internal/attachments/telemetry.go +++ b/internal/attachments/telemetry.go @@ -2,36 +2,48 @@ package attachments import "github.com/cli/cli/v2/internal/gh/ghtelemetry" -// BeginTelemetry records attachment usage and promotes full sampling. -// Call it immediately before Flag.UserAssets so rejected attachments are counted. -// It returns nil if the flag was not passed or the flag or recorder is absent. -func BeginTelemetry(flag *Flag, recorder ghtelemetry.InvocationRecorder, command string) ghtelemetry.PendingEvent { - if recorder == nil || flag == nil || !flag.Changed() { +// TelemetryEvent tracks attachment-specific facts in a pending telemetry event. +// [ghtelemetry.Service.Finish] owns completion; later updates are ignored. +type TelemetryEvent struct { + pendingEvent ghtelemetry.PendingEvent +} + +// Begin starts an attachment event at full sampling for the raw supplied count. +// A zero count returns nil without changing sampling. recorder is required; +// use a no-op service when telemetry is disabled. +// Call immediately before Flag.UserAssets so invalid and over-limit inputs count. +func Begin(recorder ghtelemetry.InvocationRecorder, command string, attachCount int) *TelemetryEvent { + if attachCount == 0 { return nil } recorder.SetSampleRate(ghtelemetry.SAMPLE_ALL) - return recorder.Begin(ghtelemetry.Event{ + pendingEvent := recorder.Begin(ghtelemetry.Event{ Type: "attachment_invocation", Dimensions: ghtelemetry.Dimensions{ "command": command, }, Measures: ghtelemetry.Measures{ - "attach_count": int64(len(flag.values)), + "attach_count": int64(attachCount), "append_ops_count": 0, "replace_ops_count": 0, }, }) + + return &TelemetryEvent{ + pendingEvent: pendingEvent, + } } -// RecordOperations upserts completed markdown operation counts, including partial results. -// A nil event means there is no attachment telemetry to update. -func RecordOperations(event ghtelemetry.PendingEvent, result UploadResult) { - if event == nil { +// RecordOperations replaces, rather than adds to, the completed operation counts. +// Pass partial results before handling an upload error so successful work is retained. +// A nil event, returned by Begin for zero attachments, is a no-op. +func (e *TelemetryEvent) RecordOperations(result UploadResult) { + if e == nil { return } - event.UpsertMeasures(ghtelemetry.Measures{ + e.pendingEvent.UpsertMeasures(ghtelemetry.Measures{ "append_ops_count": int64(result.AppendOperations), "replace_ops_count": int64(result.ReplaceOperations), }) diff --git a/internal/attachments/test.go b/internal/attachments/test.go index f93e29e7fc8..af6b6e649aa 100644 --- a/internal/attachments/test.go +++ b/internal/attachments/test.go @@ -40,18 +40,6 @@ func NewTestAssets(t *testing.T, names ...string) []UserAsset { return assets } -// BeginTestTelemetry returns a pending event with the given attachment count. -func BeginTestTelemetry(t *testing.T, recorder ghtelemetry.InvocationRecorder, attachCount int) ghtelemetry.PendingEvent { - t.Helper() - - cmd := &cobra.Command{Use: "test"} - attachFlag := AddFlag(cmd) - for i := range attachCount { - require.NoError(t, cmd.Flags().Set(flagName, "attachment-"+strconv.Itoa(i)+".png")) - } - return BeginTelemetry(attachFlag, recorder, "gh test") -} - // AssertTestTelemetryEvents verifies the completed invocation event shape. func AssertTestTelemetryEvents(t *testing.T, events []ghtelemetry.Event, attachCount int, result UploadResult) { t.Helper() diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index e7fbb48f3f9..de1c012b7ce 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -59,7 +59,7 @@ type CreateOptions struct { Blocking []string AttachFlag *attachments.Flag - AttachEvent ghtelemetry.PendingEvent + AttachEvent *attachments.TelemetryEvent Assets []attachments.UserAsset } @@ -165,7 +165,7 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, return err } - opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + opts.AttachEvent = attachments.Begin(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err @@ -467,7 +467,7 @@ func createRun(opts *CreateOptions) (err error) { // issue is created and the failures are reported. if uploader != nil { body, uploadResult, uploadErr := uploader.UploadAndAttach(context.Background(), tb.Body, opts.Assets) - attachments.RecordOperations(opts.AttachEvent, uploadResult) + opts.AttachEvent.RecordOperations(uploadResult) if uploadErr != nil && uploadResult.Uploaded == 0 { err = uploadErr return diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index a0cbf7efd92..2902b3893f2 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -1438,9 +1438,10 @@ func Test_createRun(t *testing.T) { if len(tt.attach) > 0 { opts.Assets = attachments.NewTestAssets(t, tt.attach...) } + // Given a pending event for the supplied attachments attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - opts.AttachEvent = attachments.BeginTestTelemetry(t, attachmentRecorder, len(tt.attach)) + opts.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(tt.attach)) } opts.Config = func() (gh.Config, error) { cfg := tt.config @@ -1450,7 +1451,9 @@ func Test_createRun(t *testing.T) { return config.NewMockConfigFromString(cfg), nil } + // When issue creation processes the attachments err := createRun(opts) + // Then telemetry retains completed operations, including partial results if tt.wantOperations != nil { attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index e7ceeb25701..0ae1f08f65b 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -52,7 +52,7 @@ type EditOptions struct { RemoveBlocking []string AttachFlag *attachments.Flag - AttachEvent ghtelemetry.PendingEvent + AttachEvent *attachments.TelemetryEvent Assets []attachments.UserAsset Config func() (gh.Config, error) @@ -212,7 +212,7 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, ru opts.Editable.IssueType.Edited = true } - opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + opts.AttachEvent = attachments.Begin(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err @@ -419,7 +419,7 @@ func editRun(opts *EditOptions) error { // prompt or cancel may follow an upload. var uploadResult attachments.UploadResult body, uploadResult, uploadErr = uploader.UploadAndAttach(context.Background(), body, opts.Assets) - attachments.RecordOperations(opts.AttachEvent, uploadResult) + opts.AttachEvent.RecordOperations(uploadResult) if uploadResult.Uploaded > 0 { editable.Body.Value = body diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index f1276319aa5..8f98d07fb37 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -1779,9 +1779,10 @@ func Test_editRun(t *testing.T) { if len(tt.attach) > 0 { tt.input.Assets = attachments.NewTestAssets(t, tt.attach...) } + // Given a pending event for the supplied attachments attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - tt.input.AttachEvent = attachments.BeginTestTelemetry(t, attachmentRecorder, len(tt.attach)) + tt.input.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(tt.attach)) } hostTokens := tt.hostTokens @@ -1792,7 +1793,9 @@ func Test_editRun(t *testing.T) { return config.NewMockConfigFromString(hostsConfig(hostTokens)), nil } + // When issue editing processes the attachments err := editRun(tt.input) + // Then telemetry retains completed operations, including partial results if tt.wantOperations != nil { attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index decef6ff395..533d8dd5fa5 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -78,7 +78,7 @@ type CreateOptions struct { DryRun bool AttachFlag *attachments.Flag - AttachEvent ghtelemetry.PendingEvent + AttachEvent *attachments.TelemetryEvent Assets []attachments.UserAsset } @@ -369,7 +369,7 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, return err } - opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + opts.AttachEvent = attachments.Begin(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err @@ -1128,7 +1128,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS var uploadErr error if uploader != nil { body, uploadResult, err := uploader.UploadAndAttach(context.Background(), state.Body, opts.Assets) - attachments.RecordOperations(opts.AttachEvent, uploadResult) + opts.AttachEvent.RecordOperations(uploadResult) // With nothing uploaded, a body that lost the files it was written // around is not what the caller asked to create. The branch is already // pushed by now, so the message says what was not created rather than diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 115fb512600..3594e49ecbd 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -2063,9 +2063,10 @@ func Test_createRun(t *testing.T) { cleanSetup = tt.setup(&opts, t) } defer cleanSetup() + // Given a pending event for the supplied attachments attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - opts.AttachEvent = attachments.BeginTestTelemetry(t, attachmentRecorder, len(opts.Assets)) + opts.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(opts.Assets)) } // All tests in this function use github.com behavior @@ -2075,7 +2076,9 @@ func Test_createRun(t *testing.T) { cs.Register(`git status --porcelain`, 0, "") } + // When pull request creation processes the attachments err := createRun(&opts) + // Then telemetry retains completed operations, including partial results if tt.wantOperations != nil { attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(opts.Assets), *tt.wantOperations) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index c2e07e5a29f..c226ed8f535 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -41,7 +41,7 @@ type EditOptions struct { Interactive bool AttachFlag *attachments.Flag - AttachEvent ghtelemetry.PendingEvent + AttachEvent *attachments.TelemetryEvent Assets []attachments.UserAsset Config func() (gh.Config, error) @@ -221,7 +221,7 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, ru } var err error - opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + opts.AttachEvent = attachments.Begin(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err @@ -425,7 +425,7 @@ func editRun(opts *EditOptions) error { // Nothing that can prompt or cancel may follow this. var uploadResult attachments.UploadResult body, uploadResult, uploadErr = uploader.UploadAndAttach(context.Background(), body, opts.Assets) - attachments.RecordOperations(opts.AttachEvent, uploadResult) + opts.AttachEvent.RecordOperations(uploadResult) // With nothing uploaded, even a body the caller typed goes unwritten: // its references are still local paths, which render broken. The other diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index d9ff48ae3e0..ca2961ceb91 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -1703,9 +1703,10 @@ func Test_editRun(t *testing.T) { if len(tt.attach) > 0 { tt.input.Assets = attachments.NewTestAssets(t, tt.attach...) } + // Given a pending event for the supplied attachments attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - tt.input.AttachEvent = attachments.BeginTestTelemetry(t, attachmentRecorder, len(tt.attach)) + tt.input.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(tt.attach)) } // The host comes from the pull request the row's finder returns, so @@ -1725,7 +1726,9 @@ func Test_editRun(t *testing.T) { var lookupFields []string tt.input.Finder = fieldCapturingFinder{PRFinder: tt.input.Finder, fields: &lookupFields} + // When pull request editing processes the attachments err := editRun(tt.input) + // Then telemetry retains completed operations, including partial results if tt.wantOperations != nil { attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go index 70d43cb0eb3..b323d2e0a43 100644 --- a/pkg/cmd/pr/shared/commentable.go +++ b/pkg/cmd/pr/shared/commentable.go @@ -62,7 +62,7 @@ type CommentableOptions struct { BodyProvided bool KeepExistingBody bool AttachFlag *attachments.Flag - AttachEvent ghtelemetry.PendingEvent + AttachEvent *attachments.TelemetryEvent Assets []attachments.UserAsset Config func() (gh.Config, error) } @@ -106,7 +106,7 @@ func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions, telemetry g } var err error - opts.AttachEvent = attachments.BeginTelemetry(opts.AttachFlag, telemetry, cmd.CommandPath()) + opts.AttachEvent = attachments.Begin(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err @@ -357,7 +357,7 @@ func bodyForWrite(opts *CommentableOptions, uploader *attachments.Uploader) (bod return opts.Body, true, nil } body, uploadResult, err := uploader.UploadAndAttach(context.Background(), opts.Body, opts.Assets) - attachments.RecordOperations(opts.AttachEvent, uploadResult) + opts.AttachEvent.RecordOperations(uploadResult) if err != nil && uploadResult.Uploaded == 0 { return "", false, err } diff --git a/pkg/cmd/pr/shared/commentable_test.go b/pkg/cmd/pr/shared/commentable_test.go index 1634edfbc3a..4cfd84826c2 100644 --- a/pkg/cmd/pr/shared/commentable_test.go +++ b/pkg/cmd/pr/shared/commentable_test.go @@ -150,7 +150,7 @@ func TestCommentablePreRun(t *testing.T) { cmd := commentableCmd(t, opts, tt.input) // When comment preparation validates those inputs - err := CommentablePreRun(cmd, opts, nil) + err := CommentablePreRun(cmd, opts, &telemetry.NoOpService{}) // Then it reports the validation error or prepares the comment options if tt.wantErr != "" { @@ -454,9 +454,10 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { if len(tt.attach) > 0 { opts.Assets = attachments.NewTestAssets(t, tt.attach...) } + // Given a pending event for the supplied attachments attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - opts.AttachEvent = attachments.BeginTestTelemetry(t, attachmentRecorder, len(tt.attach)) + opts.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(tt.attach)) } host := tt.host @@ -496,7 +497,9 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { }, ghrepo.NewWithHost("OWNER", "REPO", host), nil } + // When writing the comment processes the attachments err := CommentableRun(&opts) + // Then telemetry retains completed operations, including partial results if tt.wantOperations != nil { attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) From b54469f47e2e31d29d4928de3ea9d7fc14b6b076 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 9 Sep 2026 11:02:27 +0200 Subject: [PATCH 14/22] simplify telemetry test coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- internal/attachments/flags_test.go | 170 ++++++++--------- internal/telemetry/service_test.go | 31 ++-- internal/telemetry/telemetry_test.go | 252 +++++--------------------- pkg/cmd/issue/comment/comment_test.go | 6 +- pkg/cmd/issue/create/create_test.go | 29 ++- pkg/cmd/issue/edit/edit_test.go | 20 +- pkg/cmd/pr/comment/comment_test.go | 2 +- pkg/cmd/pr/create/create_test.go | 27 ++- pkg/cmd/pr/edit/edit_test.go | 24 +-- pkg/cmd/pr/shared/commentable_test.go | 41 ++--- pkg/cmdutil/telemetry_test.go | 4 + 11 files changed, 200 insertions(+), 406 deletions(-) diff --git a/internal/attachments/flags_test.go b/internal/attachments/flags_test.go index eb84daa79d5..2aa43407f72 100644 --- a/internal/attachments/flags_test.go +++ b/internal/attachments/flags_test.go @@ -11,7 +11,6 @@ import ( "github.com/cli/cli/v2/internal/telemetry" "github.com/google/shlex" "github.com/spf13/cobra" - "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -48,6 +47,8 @@ func assetsFromArgs(t *testing.T, args ...string) ([]UserAsset, error) { } func TestAddFlag(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -77,108 +78,88 @@ func TestAddFlag(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Given a command with the repeatable attachment flag cmd, attachFlag := attachCmd(t, tt.input) - slice, ok := attachFlag.flag.Value.(pflag.SliceValue) - require.True(t, ok) - assert.Equal(t, tt.want, slice.GetSlice()) - assert.Equal(t, tt.input != "", attachFlag.Changed()) - assert.Empty(t, attachFlag.flag.Shorthand) - assert.Equal(t, "Attach an image or video `file`, in '#' format", attachFlag.flag.Usage) - assert.Same(t, attachFlag.flag, cmd.Flags().Lookup(flagName)) + // When the parsed flag values are read + values, err := cmd.Flags().GetStringArray("attach") + + // Then values retain their spelling and order + require.NoError(t, err) + assert.Equal(t, tt.want, values) + assert.Equal(t, tt.input != "", attachFlag.Changed(), "Changed reports whether --attach was supplied") + flag := cmd.Flags().Lookup("attach") + assert.Empty(t, flag.Shorthand) + assert.Equal(t, "Attach an image or video `file`, in '#' format", flag.Usage) }) } } func TestAttachmentTelemetry(t *testing.T) { + t.Parallel() + + t.Run("flag not passed ignores operation updates", func(t *testing.T) { + t.Parallel() + + // Given a command with no attachment flag + _, attachFlag := attachCmd(t, "") + recorder := &telemetry.InvocationRecorderSpy{} + + // When an operation update is attempted without an attachment event + event := Begin(recorder, "gh issue comment", attachFlag.Count()) + event.RecordOperations(UploadResult{AppendOperations: 1, ReplaceOperations: 1}) + recorder.Finish() + + // Then no event or sampling promotion occurs + assert.Nil(t, event) + assert.Empty(t, recorder.Events) + assert.Zero(t, recorder.LastSampleRate) + }) + tests := []struct { - name string - input string - wantEvent bool - wantCount int64 - operations *UploadResult - wantValidationErr string + name string + input string + wantCount int64 }{ - { - name: "flag not passed ignores operation updates", - input: "", - operations: &UploadResult{AppendOperations: 1, ReplaceOperations: 1}, - }, { 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, }, { - name: "successful markdown operations", - input: "--attach ./first.png --attach ./second.png", - wantEvent: true, - wantCount: 2, - operations: &UploadResult{ - Uploaded: 2, - AppendOperations: 1, - ReplaceOperations: 1, - }, - }, - { - name: "upload flow with no completed operations", - input: "--attach ./first.png", - wantEvent: true, - wantCount: 1, - operations: &UploadResult{}, - }, - { - name: "empty path still counts", - input: `--attach ""`, - wantEvent: true, - wantCount: 1, - wantValidationErr: "cannot attach an empty path; --attach needs a file path", + name: "empty path counts before validation", + input: `--attach ""`, + wantCount: 1, }, { - name: "over attachment limit", - input: strings.Repeat("--attach ./missing.png ", maxAttachments+1), - wantEvent: true, - wantCount: maxAttachments + 1, - wantValidationErr: "`--attach` accepts at most 50 values per command", + name: "over limit counts before validation", + input: strings.Repeat("--attach ./missing.png ", maxAttachments+1), + wantCount: maxAttachments + 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Given raw attachment inputs, including values validation will reject + t.Parallel() + + // Given raw attachment inputs that have not been validated _, attachFlag := attachCmd(t, tt.input) recorder := &telemetry.InvocationRecorderSpy{} - // When telemetry begins before validation and captures operation results - event := Begin(recorder, "gh issue comment", attachFlag.Count()) - assert.Empty(t, recorder.Events) - if tt.operations != nil { - event.RecordOperations(*tt.operations) - } - if tt.wantValidationErr != "" { - _, err := attachFlag.UserAssets() - require.EqualError(t, err, tt.wantValidationErr) - } - recorder.Finish() + // When an attachment event is recorded without any upload operations + Begin(recorder, "gh issue comment", attachFlag.Count()) recorder.Finish() - // Then the completed event retains raw counts, or nothing if no flag was supplied - if !tt.wantEvent { - assert.Nil(t, event) - assert.Empty(t, recorder.Events) - assert.Zero(t, recorder.LastSampleRate) - return - } - - require.Equal(t, ghtelemetry.SAMPLE_ALL, recorder.LastSampleRate) - wantEvents := []ghtelemetry.Event{{ + // Then the raw count is retained at full sampling with zero operations + assert.Equal(t, ghtelemetry.SAMPLE_ALL, recorder.LastSampleRate) + assert.Equal(t, []ghtelemetry.Event{{ Type: "attachment_invocation", Dimensions: ghtelemetry.Dimensions{ "command": "gh issue comment", @@ -188,14 +169,29 @@ func TestAttachmentTelemetry(t *testing.T) { "append_ops_count": 0, "replace_ops_count": 0, }, - }} - if tt.operations != nil { - wantEvents[0].Measures["append_ops_count"] = int64(tt.operations.AppendOperations) - wantEvents[0].Measures["replace_ops_count"] = int64(tt.operations.ReplaceOperations) - } - require.Equal(t, wantEvents, recorder.Events) + }}, recorder.Events) }) } + + t.Run("completed markdown operations", func(t *testing.T) { + t.Parallel() + + // Given a pending event for four attachments + recorder := &telemetry.InvocationRecorderSpy{} + event := Begin(recorder, "gh issue comment", 4) + + // When four uploads produce one append and three replacements + event.RecordOperations(UploadResult{Uploaded: 4, AppendOperations: 1, ReplaceOperations: 3}) + recorder.Finish() + + // Then markdown operations are counted separately from uploaded files + require.Len(t, recorder.Events, 1) + assert.Equal(t, ghtelemetry.Measures{ + "attach_count": 4, + "append_ops_count": 1, + "replace_ops_count": 3, + }, recorder.Events[0].Measures) + }) } func TestTelemetryEventReplacesOperationCounts(t *testing.T) { @@ -266,11 +262,6 @@ func TestFlagUserAssets(t *testing.T) { wantErr: "./gone.png: ", wantErrIs: fs.ErrNotExist, }, - { - name: "several files, in the order written", - input: "--attach ./b.png --attach ./a.png", - wantPaths: []string{"./b.png", "./a.png"}, - }, { name: "too many attachments are rejected before filesystem validation", input: strings.Repeat("--attach ./missing.txt ", maxAttachments+1), @@ -283,9 +274,7 @@ func TestFlagUserAssets(t *testing.T) { wantErrIs: fs.ErrNotExist, }, { - // pflag reads this flag back as holding nothing at all, so - // without the length check the command would post with no - // attachment and no error. + // An explicitly empty value is invalid, not an absent flag. name: "a lone empty value", input: `--attach ""`, wantErr: "cannot attach an empty path; --attach needs a file path", @@ -306,7 +295,7 @@ func TestFlagUserAssets(t *testing.T) { wantPaths: []string{"./before,after.png"}, }, { - name: "keeps the order the arguments were written in", + name: "keeps order for distinct files with identical contents", input: "--attach './b.png#Second' --attach ./a.png --attach ./c.mp4", wantPaths: []string{"./b.png", "./a.png", "./c.mp4"}, }, @@ -341,12 +330,6 @@ func TestFlagUserAssets(t *testing.T) { input: "--attach ./a.png --attach ./hard.png", wantErr: "./a.png and ./hard.png are the same file; attached files must be unique", }, - { - // GitHub gives each its own asset URL. - name: "two separate files with identical contents", - input: "--attach ./a.png --attach ./b.png", - wantPaths: []string{"./a.png", "./b.png"}, - }, { name: "reports the first invalid file", input: "--attach ./a.png --attach ./notes.txt", @@ -356,6 +339,7 @@ func TestFlagUserAssets(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Given the named files and raw attachment arguments t.Chdir(t.TempDir()) for _, name := range []string{ "shot.png", @@ -376,8 +360,10 @@ func TestFlagUserAssets(t *testing.T) { _, attachFlag := attachCmd(t, tt.input) + // When the attachment files are resolved resolved, err := attachFlag.UserAssets() + // Then invalid inputs fail or the resolved paths retain their order if tt.wantErrIs != nil { require.ErrorIs(t, err, tt.wantErrIs) require.ErrorContains(t, err, tt.wantErr) diff --git a/internal/telemetry/service_test.go b/internal/telemetry/service_test.go index 745c6324a2a..90ca47d68de 100644 --- a/internal/telemetry/service_test.go +++ b/internal/telemetry/service_test.go @@ -16,12 +16,12 @@ func TestServiceCopiesFactsAtTheirRecordingTime(t *testing.T) { synctest.Test(t, func(t *testing.T) { // Given a producer that reuses its event and update maps - var payload SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { payload = p }) + var payloads []SendTelemetryPayload + service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) facts := ghtelemetry.Event{ Type: "command_invocation", - Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, - Measures: ghtelemetry.Measures{"count": 1}, + Dimensions: ghtelemetry.Dimensions{"command": "gh issue create", "flags": ""}, + Measures: ghtelemetry.Measures{"count": 1, "total": 2}, } startedAt := time.Now() pending := service.Begin(facts) @@ -39,18 +39,22 @@ func TestServiceCopiesFactsAtTheirRecordingTime(t *testing.T) { dimensions["flags"] = "unrelated" measures["count"] = 99 time.Sleep(time.Second) + require.Empty(t, payloads, "recording and updating facts must not send them before Finish") service.Finish() // Then event order, original timestamps, and independently owned facts survive - require.Len(t, payload.Events, 2) - first, second := payload.Events[0], payload.Events[1] + require.Len(t, payloads, 1) + require.Len(t, payloads[0].Events, 2) + first, second := payloads[0].Events[0], payloads[0].Events[1] assert.Equal(t, "command_invocation", first.Type) assert.Equal(t, "gh issue create", first.Dimensions["command"]) assert.Equal(t, "attach", first.Dimensions["flags"]) assert.Equal(t, int64(2), first.Measures["count"]) + assert.Equal(t, int64(2), first.Measures["total"]) assert.Equal(t, startedAt.UTC().Format("2006-01-02T15:04:05.000Z"), first.Dimensions["timestamp"]) assert.Equal(t, "completed_step", second.Type) assert.Equal(t, "gh issue create", second.Dimensions["command"]) + assert.Empty(t, second.Dimensions["flags"]) assert.Equal(t, int64(1), second.Measures["count"]) assert.Equal(t, startedAt.Add(time.Second).UTC().Format("2006-01-02T15:04:05.000Z"), second.Dimensions["timestamp"]) }) @@ -61,14 +65,19 @@ func TestServicePromotesAllEventsBeforeCompletion(t *testing.T) { // Given a command that discovers its full-sampling policy after recording facts var payload SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { payload = p }, WithSampleRate(1)) - service.Record(ghtelemetry.Event{Type: "completed_step"}) - pending := service.Begin(ghtelemetry.Event{Type: "attachment_invocation"}) + svc := NewService(func(p SendTelemetryPayload) { payload = p }, + WithSampleRate(1), + WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"sample_rate": "1"}), + ) + // Promotion must rescue an invocation that would otherwise be excluded. + svc.(*service).sampleBucket = 99 + svc.Record(ghtelemetry.Event{Type: "completed_step"}) + pending := svc.Begin(ghtelemetry.Event{Type: "attachment_invocation"}) // When attachment usage promotes the invocation before it finishes - service.SetSampleRate(ghtelemetry.SAMPLE_ALL) + svc.SetSampleRate(ghtelemetry.SAMPLE_ALL) pending.UpsertMeasures(ghtelemetry.Measures{"attach_count": 2}) - service.Finish() + svc.Finish() // Then immediate and pending events share the promoted sampling policy require.Len(t, payload.Events, 2) diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index bcfe8f59ca4..9b2c4b67d9a 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -8,8 +8,6 @@ import ( "strings" "sync" "testing" - "testing/synctest" - "time" "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/stretchr/testify/assert" @@ -349,43 +347,6 @@ func TestLogFlusherWritesNoneMarkerForEmptyPayload(t *testing.T) { }) } -func TestServiceFinishSendsPendingEvents(t *testing.T) { - t.Cleanup(stubDeviceID("test-device")) - - // Given an invocation whose attachment operations are not yet known - var payloads []SendTelemetryPayload - service := NewService(func(payload SendTelemetryPayload) { - payloads = append(payloads, payload) - }) - event := service.Begin(ghtelemetry.Event{ - Type: "attachment_invocation", - Measures: ghtelemetry.Measures{ - "attach_count": 2, - "append_ops_count": 0, - "replace_ops_count": 0, - }, - }) - - require.Empty(t, payloads, "unfinished events must not be sent") - - // When the command supplies its operations and finishes the invocation - event.UpsertMeasures(ghtelemetry.Measures{ - "append_ops_count": 1, - "replace_ops_count": 1, - }) - service.Finish() - - // Then Finish sends the completed snapshot without a separate flush - require.Len(t, payloads, 1) - require.Len(t, payloads[0].Events, 1) - assert.Equal(t, "attachment_invocation", payloads[0].Events[0].Type) - assert.Equal(t, map[string]int64{ - "attach_count": 2, - "append_ops_count": 1, - "replace_ops_count": 1, - }, payloads[0].Events[0].Measures) -} - func TestServiceDeviceIDFallback(t *testing.T) { // Given device ID discovery fails t.Cleanup(stubDeviceIDError(errors.New("no device id"))) @@ -419,7 +380,12 @@ func TestServiceFinish(t *testing.T) { // Given an invocation with common dimensions t.Cleanup(stubDeviceID("test-device")) var captured SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { captured = p }, WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"version": "2.45.0"})) + service := NewService(func(p SendTelemetryPayload) { captured = p }, + WithAdditionalCommonDimensions(ghtelemetry.Dimensions{ + "version": "2.45.0", + "agent": "none", + }), + ) // When an event with its own dimensions and measures is completed and delivered service.Record(ghtelemetry.Event{ @@ -435,47 +401,15 @@ func TestServiceFinish(t *testing.T) { 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, "none", event.Dimensions["agent"]) assert.Equal(t, "test-device", event.Dimensions["device_id"]) assert.NotEmpty(t, event.Dimensions["timestamp"]) assert.NotEmpty(t, event.Dimensions["invocation_id"]) + assert.NotEmpty(t, event.Dimensions["os"]) + assert.NotEmpty(t, event.Dimensions["architecture"]) assert.Equal(t, int64(150), event.Measures["duration_ms"]) }) - t.Run("delivers multiple events", func(t *testing.T) { - // Given an invocation with two recorded events - t.Cleanup(stubDeviceID("test-device")) - var captured SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { captured = p }) - service.Record(ghtelemetry.Event{Type: "event1"}) - service.Record(ghtelemetry.Event{Type: "event2"}) - - // When the invocation finishes - service.Finish() - - // Then both events are delivered in recording order - 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) { - // Given an invocation with a recorded event - t.Cleanup(stubDeviceID("test-device")) - var payloads []SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - service.Record(ghtelemetry.Event{Type: "test"}) - - // When completion is repeated - service.Finish() - service.Finish() - service.Finish() - - // Then the recorded event is delivered exactly once - require.Len(t, payloads, 1) - require.Len(t, payloads[0].Events, 1) - assert.Equal(t, "test", payloads[0].Events[0].Type) - }) - t.Run("event dimensions override common dimensions", func(t *testing.T) { // Given common and event dimensions share a key t.Cleanup(stubDeviceID("test-device")) @@ -493,33 +427,6 @@ func TestServiceFinish(t *testing.T) { require.Len(t, captured.Events, 1) assert.Equal(t, "event-level", captured.Events[0].Dimensions["shared"]) }) - - t.Run("timestamps reflect record time not completion time", func(t *testing.T) { - t.Cleanup(stubDeviceID("test-device")) - synctest.Test(t, func(t *testing.T) { - // Given events recorded at distinct times - var captured SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { captured = p }) - firstRecordedAt := time.Now() - service.Record(ghtelemetry.Event{Type: "early"}) - time.Sleep(50 * time.Millisecond) - secondRecordedAt := time.Now() - service.Record(ghtelemetry.Event{Type: "late"}) - - // When completion happens later - time.Sleep(time.Second) - service.Finish() - - // Then each timestamp reflects when its event was recorded - require.Len(t, captured.Events, 2) - firstTimestamp, err := time.Parse("2006-01-02T15:04:05.000Z", captured.Events[0].Dimensions["timestamp"]) - require.NoError(t, err) - secondTimestamp, err := time.Parse("2006-01-02T15:04:05.000Z", captured.Events[1].Dimensions["timestamp"]) - require.NoError(t, err) - assert.WithinDuration(t, firstRecordedAt, firstTimestamp, 0) - assert.WithinDuration(t, secondRecordedAt, secondTimestamp, 0) - }) - }) } func TestServiceSampling(t *testing.T) { @@ -570,132 +477,55 @@ func TestServiceSampling(t *testing.T) { svc.(*service).sampleBucket = tt.sampleBucket // When the invocation finishes - svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Record(ghtelemetry.Event{Type: "completed_step"}) + pending := svc.Begin(ghtelemetry.Event{Type: "attachment_invocation"}) + pending.UpsertMeasures(ghtelemetry.Measures{"attach_count": 2}) svc.Finish() - // Then only invocations selected by sampling are delivered + // Then sampling includes or excludes immediate and pending events together require.Len(t, payloads, tt.wantPayloads) for _, payload := range payloads { - require.Len(t, payload.Events, 1) - assert.Equal(t, "test", payload.Events[0].Type) + require.Len(t, payload.Events, 2) + assert.Equal(t, "completed_step", payload.Events[0].Type) + assert.Equal(t, "attachment_invocation", payload.Events[1].Type) + assert.Equal(t, int64(2), payload.Events[1].Measures["attach_count"]) } }) } } -func TestServiceSetSampleRate(t *testing.T) { - t.Run("changes delivery eligibility", func(t *testing.T) { - // Given an invocation that initially sends all events - t.Cleanup(stubDeviceID("test-device")) - var payloads []SendTelemetryPayload - svc := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }, WithSampleRate(0)) - svc.(*service).sampleBucket = 50 - - // When its sample rate excludes the bucket before completion - svc.SetSampleRate(10) - svc.Record(ghtelemetry.Event{Type: "test"}) - svc.Finish() - - // Then no payload is delivered - assert.Empty(t, payloads) - }) - - t.Run("updates sample_rate dimension", func(t *testing.T) { - // Given an invocation with an initial sample_rate dimension - t.Cleanup(stubDeviceID("test-device")) - var captured SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { captured = p }, - WithSampleRate(1), - WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"sample_rate": "1"}), - ) +func TestServiceReducingSampleRateExcludesInvocation(t *testing.T) { + // Given an invocation that initially sends all events + t.Cleanup(stubDeviceID("test-device")) + var payloads []SendTelemetryPayload + svc := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }, WithSampleRate(0)) + svc.(*service).sampleBucket = 50 - // When the rate changes before completion - service.SetSampleRate(100) - service.Record(ghtelemetry.Event{Type: "test"}) - service.Finish() + // When its sample rate excludes the bucket before completion + svc.SetSampleRate(10) + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Finish() - // Then the payload describes the effective sample rate - require.Len(t, captured.Events, 1) - assert.Equal(t, "100", captured.Events[0].Dimensions["sample_rate"]) - }) + // Then no payload is delivered + assert.Empty(t, payloads) } -func TestWithAdditionalCommonDimensions(t *testing.T) { - // Given an invocation constructed with additional common dimensions +func TestServiceDisabledBeforeRecordingDropsLaterEvents(t *testing.T) { + // Given an invocation disabled before any events are recorded t.Cleanup(stubDeviceID("test-device")) - var captured SendTelemetryPayload - service := NewService( - func(p SendTelemetryPayload) { captured = p }, - WithAdditionalCommonDimensions(ghtelemetry.Dimensions{ - "version": "2.45.0", - "agent": "none", - }), - ) + var payloads []SendTelemetryPayload + service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) + service.Disable() - // When a recorded event is completed and delivered - service.Record(ghtelemetry.Event{Type: "test"}) + // When immediate and pending events are recorded and the invocation completes + service.Record(ghtelemetry.Event{Type: "completed_step"}) + pending := service.Begin(ghtelemetry.Event{Type: "attachment_invocation"}) + pending.UpsertMeasures(ghtelemetry.Measures{"attach_count": 2}) service.Finish() - // Then both additional and standard dimensions are present - 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"]) - 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 TestServiceDisable(t *testing.T) { - t.Run("drops recorded events from delivered payload", func(t *testing.T) { - // Given an invocation with a recorded event - t.Cleanup(stubDeviceID("test-device")) - var payloads []SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - service.Record(ghtelemetry.Event{Type: "test"}) - - // When telemetry is disabled before completion - service.Disable() - service.Finish() - - // Then an empty payload is delivered so log mode can surface the absence - require.Len(t, payloads, 1) - assert.Empty(t, payloads[0].Events, "recorded events should be dropped after Disable()") - }) - - t.Run("drops events even with multiple recorded events", func(t *testing.T) { - // Given an invocation with multiple recorded events - t.Cleanup(stubDeviceID("test-device")) - var payloads []SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - service.Record(ghtelemetry.Event{Type: "event1"}) - service.Record(ghtelemetry.Event{Type: "event2"}) - service.Record(ghtelemetry.Event{Type: "event3"}) - - // When telemetry is disabled before completion - service.Disable() - service.Finish() - - // Then none of the recorded events appear in the delivered payload - require.Len(t, payloads, 1) - assert.Empty(t, payloads[0].Events, "recorded events should be dropped after Disable()") - }) - - t.Run("can be called before any events are recorded", func(t *testing.T) { - // Given an invocation disabled before any events are recorded - t.Cleanup(stubDeviceID("test-device")) - var payloads []SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - service.Disable() - - // When an event is recorded and the invocation completes - service.Record(ghtelemetry.Event{Type: "test"}) - service.Finish() - - // Then the later event is excluded from the delivered payload - require.Len(t, payloads, 1) - assert.Empty(t, payloads[0].Events, "events recorded after Disable() should be dropped") - }) + // Then the later events are excluded from the delivered payload + require.Len(t, payloads, 1) + assert.Empty(t, payloads[0].Events, "events recorded after Disable() should be dropped") } func TestNoOpService(t *testing.T) { diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index 6668724dcfa..254a872fd29 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -390,7 +390,7 @@ func TestNewCmdComment(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Given command inputs and their expected attachment telemetry + // Given command inputs and an invocation recorder ios, stdin, _, _ := iostreams.Test() isTTY := tt.isTTY ios.SetStdoutTTY(isTTY) @@ -499,7 +499,6 @@ func TestNewCmdCommentRecordsRejectedAttachmentCount(t *testing.T) { f := &cmdutil.Factory{ IOStreams: ios, Browser: &browser.Stub{}, - Config: testConfig(), } var payload telemetry.SendTelemetryPayload service := telemetry.NewService(func(p telemetry.SendTelemetryPayload) { @@ -542,7 +541,6 @@ func TestNewCmdCommentSkipsAttachmentsOnPersistentPreRunError(t *testing.T) { f := &cmdutil.Factory{ IOStreams: ios, Browser: &browser.Stub{}, - Config: testConfig(), } recorder := &telemetry.InvocationRecorderSpy{} cmd := NewCmdComment(f, recorder, func(*shared.CommentableOptions) error { @@ -561,10 +559,10 @@ func TestNewCmdCommentSkipsAttachmentsOnPersistentPreRunError(t *testing.T) { // When authentication fails and telemetry is completed _, err := root.ExecuteC() - require.EqualError(t, err, "authentication failed") recorder.Finish() // Then attachment validation has not begun, so no event or sampling promotion occurs + require.EqualError(t, err, "authentication failed") assert.Empty(t, recorder.Events) assert.Zero(t, recorder.LastSampleRate) } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 2902b3893f2..725599c04b5 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -314,7 +314,7 @@ func TestNewCmdCreate(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Given command inputs and their expected attachment telemetry + // Given command inputs and an invocation recorder ios, stdin, stdout, stderr := iostreams.Test() if tt.stdin != "" { _, _ = stdin.WriteString(tt.stdin) @@ -608,13 +608,10 @@ func Test_createRun(t *testing.T) { return "title", "from editor ![shot](./shot.png)", nil }, }, - attach: []string{"shot.png"}, - wantsStdout: "https://github.com/OWNER/REPO/issues/12\n", - wantsStderr: "\nCreating issue in OWNER/REPO\n\n", - wantOperations: &attachments.UploadResult{ - Uploaded: 1, - ReplaceOperations: 1, - }, + attach: []string{"shot.png"}, + wantsStdout: "https://github.com/OWNER/REPO/issues/12\n", + wantsStderr: "\nCreating issue in OWNER/REPO\n\n", + wantOperations: &attachments.UploadResult{ReplaceOperations: 1}, }, { name: "editor and template", @@ -1204,11 +1201,8 @@ func Test_createRun(t *testing.T) { assert.Equal(t, "a body\n\n![first](https://github.com/user-attachments/assets/AAA)", inputs["body"]) })) }, - wantsErr: "could not upload ./second.png: attaching files requires write access to the repository", - wantOperations: &attachments.UploadResult{ - Uploaded: 1, - AppendOperations: 1, - }, + wantsErr: "could not upload ./second.png: attaching files requires write access to the repository", + wantOperations: &attachments.UploadResult{AppendOperations: 1}, }, { name: "a create that fails after an upload failed reports both", @@ -1234,7 +1228,8 @@ func Test_createRun(t *testing.T) { httpmock.GraphQL(`mutation IssueCreate\b`), httpmock.StringResponse(`{ "errors": [{ "message": "the create failed" }] }`)) }, - wantsErr: "could not upload ./second.png: attaching files requires write access to the repository\nGraphQL: the create failed", + wantsErr: "could not upload ./second.png: attaching files requires write access to the repository\nGraphQL: the create failed", + wantOperations: &attachments.UploadResult{AppendOperations: 1}, }, { name: "a body the attachment cannot be written into creates no issue", @@ -1438,7 +1433,7 @@ func Test_createRun(t *testing.T) { if len(tt.attach) > 0 { opts.Assets = attachments.NewTestAssets(t, tt.attach...) } - // Given a pending event for the supplied attachments + // Given a pending event when operation counts are under test attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { opts.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(tt.attach)) @@ -1451,10 +1446,10 @@ func Test_createRun(t *testing.T) { return config.NewMockConfigFromString(cfg), nil } - // When issue creation processes the attachments + // When issue creation runs err := createRun(opts) - // Then telemetry retains completed operations, including partial results if tt.wantOperations != nil { + // Then telemetry retains completed operations, including partial results attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) } diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 8f98d07fb37..8763691d59d 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -470,7 +470,7 @@ func TestNewCmdEdit(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Given command inputs and their expected attachment telemetry + // Given command inputs and an invocation recorder ios, stdin, _, _ := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStdinTTY(true) @@ -1403,11 +1403,8 @@ func Test_editRun(t *testing.T) { mockIssueGetWithRepository(reg, "the original body", 1234, "WRITE") mockIssueUpdateWithBody(t, reg, "the original body\n\n![shot](https://example.com/1)") }, - stdout: "https://github.com/OWNER/REPO/issue/123\n", - wantOperations: &attachments.UploadResult{ - Uploaded: 1, - AppendOperations: 1, - }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + wantOperations: &attachments.UploadResult{AppendOperations: 1}, }, { name: "a body flag replaces the body the attachment is then appended to", @@ -1562,10 +1559,7 @@ func Test_editRun(t *testing.T) { stdout: "https://github.com/OWNER/REPO/issue/123\n", wantErr: true, wantErrContains: []string{"./second.png"}, - wantOperations: &attachments.UploadResult{ - Uploaded: 1, - AppendOperations: 1, - }, + wantOperations: &attachments.UploadResult{AppendOperations: 1}, }, { name: "a sole failed upload does not write the body", @@ -1779,7 +1773,7 @@ func Test_editRun(t *testing.T) { if len(tt.attach) > 0 { tt.input.Assets = attachments.NewTestAssets(t, tt.attach...) } - // Given a pending event for the supplied attachments + // Given a pending event when operation counts are under test attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { tt.input.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(tt.attach)) @@ -1793,10 +1787,10 @@ func Test_editRun(t *testing.T) { return config.NewMockConfigFromString(hostsConfig(hostTokens)), nil } - // When issue editing processes the attachments + // When issue editing runs err := editRun(tt.input) - // Then telemetry retains completed operations, including partial results if tt.wantOperations != nil { + // Then telemetry retains completed operations, including partial results attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) } diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go index 6d1b84ecf92..d3c3c3a30e6 100644 --- a/pkg/cmd/pr/comment/comment_test.go +++ b/pkg/cmd/pr/comment/comment_test.go @@ -411,7 +411,7 @@ func TestNewCmdComment(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Given command inputs and their expected attachment telemetry + // Given command inputs and an invocation recorder ios, stdin, _, _ := iostreams.Test() isTTY := tt.isTTY ios.SetStdoutTTY(isTTY) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 3594e49ecbd..233eb78ed43 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -337,7 +337,7 @@ func TestNewCmdCreate(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Given command inputs and their expected attachment telemetry + // Given command inputs and an invocation recorder ios, stdin, stdout, stderr := iostreams.Test() if tt.stdin != "" { _, _ = stdin.WriteString(tt.stdin) @@ -1789,11 +1789,8 @@ func Test_createRun(t *testing.T) { assert.Equal(t, "before ![the shot](https://github.com/user-attachments/assets/ASSET) after", input["body"]) })) }, - expectedOut: "https://github.com/OWNER/REPO/pull/12\n", - wantOperations: &attachments.UploadResult{ - Uploaded: 1, - ReplaceOperations: 1, - }, + expectedOut: "https://github.com/OWNER/REPO/pull/12\n", + wantOperations: &attachments.UploadResult{ReplaceOperations: 1}, }, { @@ -1887,12 +1884,9 @@ func Test_createRun(t *testing.T) { assert.Equal(t, "my body\n\n![good](https://github.com/user-attachments/assets/ASSET)", input["body"]) })) }, - expectedOut: "https://github.com/OWNER/REPO/pull/12\n", - wantErr: "could not upload ./bad.png: attaching files requires write access to the repository", - wantOperations: &attachments.UploadResult{ - Uploaded: 1, - AppendOperations: 1, - }, + expectedOut: "https://github.com/OWNER/REPO/pull/12\n", + wantErr: "could not upload ./bad.png: attaching files requires write access to the repository", + wantOperations: &attachments.UploadResult{AppendOperations: 1}, }, { name: "the only upload failing creates no pull request", @@ -1949,7 +1943,8 @@ func Test_createRun(t *testing.T) { httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.StringResponse(`{"errors":[{"message":"the create failed"}]}`)) }, - wantErr: "could not upload ./bad.png: attaching files requires write access to the repository\npull request create failed: GraphQL: the create failed", + wantErr: "could not upload ./bad.png: attaching files requires write access to the repository\npull request create failed: GraphQL: the create failed", + wantOperations: &attachments.UploadResult{AppendOperations: 1}, }, { name: "a permission that cannot upload stops the command before it prompts", @@ -2063,7 +2058,7 @@ func Test_createRun(t *testing.T) { cleanSetup = tt.setup(&opts, t) } defer cleanSetup() - // Given a pending event for the supplied attachments + // Given a pending event when operation counts are under test attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { opts.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(opts.Assets)) @@ -2076,10 +2071,10 @@ func Test_createRun(t *testing.T) { cs.Register(`git status --porcelain`, 0, "") } - // When pull request creation processes the attachments + // When pull request creation runs err := createRun(&opts) - // Then telemetry retains completed operations, including partial results if tt.wantOperations != nil { + // Then telemetry retains completed operations, including partial results attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(opts.Assets), *tt.wantOperations) } diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index ca2961ceb91..aac43617985 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -365,7 +365,7 @@ func TestNewCmdEdit(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Given command inputs and their expected attachment telemetry + // Given command inputs and an invocation recorder ios, stdin, _, _ := iostreams.Test() ios.SetStdoutTTY(true) ios.SetStdinTTY(true) @@ -1310,11 +1310,8 @@ func Test_editRun(t *testing.T) { httpStubs: func(t *testing.T, reg *httpmock.Registry) { mockPullRequestUpdateWithBody(t, reg, "the original body\n\n![shot](https://example.com/1)") }, - stdout: "https://github.com/OWNER/REPO/pull/123\n", - wantOperations: &attachments.UploadResult{ - Uploaded: 1, - AppendOperations: 1, - }, + stdout: "https://github.com/OWNER/REPO/pull/123\n", + wantOperations: &attachments.UploadResult{AppendOperations: 1}, }, { name: "an empty body flag clears the body and leaves the attachment", @@ -1394,12 +1391,9 @@ func Test_editRun(t *testing.T) { httpStubs: func(t *testing.T, reg *httpmock.Registry) { mockPullRequestUpdateWithBody(t, reg, "the original body\n\n![a](https://example.com/1)") }, - stdout: "https://github.com/OWNER/REPO/pull/123\n", - wantErr: "could not upload ./b.png: attaching files requires write access to the repository", - wantOperations: &attachments.UploadResult{ - Uploaded: 1, - AppendOperations: 1, - }, + stdout: "https://github.com/OWNER/REPO/pull/123\n", + wantErr: "could not upload ./b.png: attaching files requires write access to the repository", + wantOperations: &attachments.UploadResult{AppendOperations: 1}, }, { name: "a sole failed upload leaves the body alone and still edits the title", @@ -1703,7 +1697,7 @@ func Test_editRun(t *testing.T) { if len(tt.attach) > 0 { tt.input.Assets = attachments.NewTestAssets(t, tt.attach...) } - // Given a pending event for the supplied attachments + // Given a pending event when operation counts are under test attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { tt.input.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(tt.attach)) @@ -1726,10 +1720,10 @@ func Test_editRun(t *testing.T) { var lookupFields []string tt.input.Finder = fieldCapturingFinder{PRFinder: tt.input.Finder, fields: &lookupFields} - // When pull request editing processes the attachments + // When pull request editing runs err := editRun(tt.input) - // Then telemetry retains completed operations, including partial results if tt.wantOperations != nil { + // Then telemetry retains completed operations, including partial results attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) } diff --git a/pkg/cmd/pr/shared/commentable_test.go b/pkg/cmd/pr/shared/commentable_test.go index 4cfd84826c2..725939c1262 100644 --- a/pkg/cmd/pr/shared/commentable_test.go +++ b/pkg/cmd/pr/shared/commentable_test.go @@ -61,8 +61,6 @@ func TestCommentablePreRun(t *testing.T) { wantBodyProvided bool }{ { - // A change that separates counting the input from resolving it - // fails here, since this row asserts both from one run. name: "attach alone is a body input and is resolved", input: "--attach ./shot.png", wantInputType: InputTypeInline, @@ -212,10 +210,7 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { wantBody: "see below\n\n![shot](https://example.com/1)", wantStdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", wantUploads: 1, - wantOperations: &attachments.UploadResult{ - Uploaded: 1, - AppendOperations: 1, - }, + wantOperations: &attachments.UploadResult{AppendOperations: 1}, }, { name: "creating writes what uploaded when one upload fails", @@ -226,15 +221,12 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { {Name: "a.png", Status: 201, Body: `{"url":"https://example.com/1"}`}, {Name: "b.png", Status: 404, Body: `{"message":"Not Found"}`}, }, - wantQuery: `mutation CommentCreate\b`, - wantBody: "see below\n\n![a](https://example.com/1)", - wantStdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", - wantErr: "could not upload ./b.png: attaching files requires write access to the repository", - wantUploads: 2, - wantOperations: &attachments.UploadResult{ - Uploaded: 1, - AppendOperations: 1, - }, + wantQuery: `mutation CommentCreate\b`, + wantBody: "see below\n\n![a](https://example.com/1)", + wantStdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", + wantErr: "could not upload ./b.png: attaching files requires write access to the repository", + wantUploads: 2, + wantOperations: &attachments.UploadResult{AppendOperations: 1}, }, { name: "creating writes nothing when every upload fails", @@ -272,14 +264,11 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { {Name: "a.png", Status: 201, Body: `{"url":"https://example.com/1"}`}, {Name: "b.png", Status: 404, Body: `{"message":"Not Found"}`}, }, - wantQuery: `mutation CommentCreate\b`, - writeFails: true, - wantErr: "could not upload ./b.png: attaching files requires write access to the repository\nGraphQL: the write failed", - wantUploads: 2, - wantOperations: &attachments.UploadResult{ - Uploaded: 1, - AppendOperations: 1, - }, + wantQuery: `mutation CommentCreate\b`, + writeFails: true, + wantErr: "could not upload ./b.png: attaching files requires write access to the repository\nGraphQL: the write failed", + wantUploads: 2, + wantOperations: &attachments.UploadResult{AppendOperations: 1}, }, { name: "editing keeps the comment when no body flag was given", @@ -454,7 +443,7 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { if len(tt.attach) > 0 { opts.Assets = attachments.NewTestAssets(t, tt.attach...) } - // Given a pending event for the supplied attachments + // Given a pending event when operation counts are under test attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { opts.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(tt.attach)) @@ -497,10 +486,10 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { }, ghrepo.NewWithHost("OWNER", "REPO", host), nil } - // When writing the comment processes the attachments + // When comment creation or editing runs err := CommentableRun(&opts) - // Then telemetry retains completed operations, including partial results if tt.wantOperations != nil { + // Then telemetry retains completed operations, including partial results attachmentRecorder.Finish() attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) } diff --git a/pkg/cmdutil/telemetry_test.go b/pkg/cmdutil/telemetry_test.go index c1d67d54baa..9faccd4bb33 100644 --- a/pkg/cmdutil/telemetry_test.go +++ b/pkg/cmdutil/telemetry_test.go @@ -249,6 +249,10 @@ func TestRecordTelemetryForSubcommands(t *testing.T) { RunE: func(cmd *cobra.Command, args []string) error { return nil }, } root.AddCommand(parent) + root.AddCommand(&cobra.Command{ + Use: "version", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + }) parent.AddCommand(child) root.SetArgs([]string{"pr", "list"}) cmdutil.RecordTelemetryForSubcommands(root, recorder) From 81780e761889d7e72f2037e48e1547b3420a9731 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 9 Sep 2026 11:36:07 +0200 Subject: [PATCH 15/22] simplify telemetry recording and tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c95db6c5-fc17-4880-915c-5d2c4b938d49 --- internal/attachments/flags_test.go | 43 ++----- internal/attachments/telemetry.go | 4 +- internal/telemetry/fake.go | 41 ++----- internal/telemetry/service_test.go | 159 ++++++++----------------- internal/telemetry/telemetry_test.go | 14 +-- pkg/cmd/issue/comment/comment_test.go | 12 +- pkg/cmd/issue/create/create.go | 2 +- pkg/cmd/issue/create/create_test.go | 10 +- pkg/cmd/issue/edit/edit.go | 2 +- pkg/cmd/issue/edit/edit_test.go | 10 +- pkg/cmd/pr/comment/comment_test.go | 5 +- pkg/cmd/pr/create/create.go | 2 +- pkg/cmd/pr/create/create_test.go | 10 +- pkg/cmd/pr/edit/edit.go | 2 +- pkg/cmd/pr/edit/edit_test.go | 10 +- pkg/cmd/pr/shared/commentable.go | 2 +- pkg/cmd/pr/shared/commentable_test.go | 5 +- pkg/cmd/skills/install/install_test.go | 10 +- pkg/cmd/skills/list/list_test.go | 7 +- pkg/cmd/skills/preview/preview_test.go | 10 +- pkg/cmd/skills/search/search_test.go | 5 +- pkg/cmdutil/telemetry_test.go | 76 ++++++------ 22 files changed, 159 insertions(+), 282 deletions(-) diff --git a/internal/attachments/flags_test.go b/internal/attachments/flags_test.go index 2aa43407f72..f125886c90c 100644 --- a/internal/attachments/flags_test.go +++ b/internal/attachments/flags_test.go @@ -108,13 +108,12 @@ func TestAttachmentTelemetry(t *testing.T) { recorder := &telemetry.InvocationRecorderSpy{} // When an operation update is attempted without an attachment event - event := Begin(recorder, "gh issue comment", attachFlag.Count()) + event := BeginTelemetry(recorder, "gh issue comment", attachFlag.Count()) event.RecordOperations(UploadResult{AppendOperations: 1, ReplaceOperations: 1}) - recorder.Finish() // Then no event or sampling promotion occurs assert.Nil(t, event) - assert.Empty(t, recorder.Events) + assert.Empty(t, recorder.Events()) assert.Zero(t, recorder.LastSampleRate) }) @@ -154,8 +153,7 @@ func TestAttachmentTelemetry(t *testing.T) { recorder := &telemetry.InvocationRecorderSpy{} // When an attachment event is recorded without any upload operations - Begin(recorder, "gh issue comment", attachFlag.Count()) - recorder.Finish() + BeginTelemetry(recorder, "gh issue comment", attachFlag.Count()) // Then the raw count is retained at full sampling with zero operations assert.Equal(t, ghtelemetry.SAMPLE_ALL, recorder.LastSampleRate) @@ -169,7 +167,7 @@ func TestAttachmentTelemetry(t *testing.T) { "append_ops_count": 0, "replace_ops_count": 0, }, - }}, recorder.Events) + }}, recorder.Events()) }) } @@ -178,45 +176,22 @@ func TestAttachmentTelemetry(t *testing.T) { // Given a pending event for four attachments recorder := &telemetry.InvocationRecorderSpy{} - event := Begin(recorder, "gh issue comment", 4) + event := BeginTelemetry(recorder, "gh issue comment", 4) // When four uploads produce one append and three replacements event.RecordOperations(UploadResult{Uploaded: 4, AppendOperations: 1, ReplaceOperations: 3}) - recorder.Finish() - // Then markdown operations are counted separately from uploaded files - require.Len(t, recorder.Events, 1) + // Then telemetry retains the attachment count and records one append and three replacements + events := recorder.Events() + require.Len(t, events, 1) assert.Equal(t, ghtelemetry.Measures{ "attach_count": 4, "append_ops_count": 1, "replace_ops_count": 3, - }, recorder.Events[0].Measures) + }, events[0].Measures) }) } -func TestTelemetryEventReplacesOperationCounts(t *testing.T) { - // Given an attachment event with previously recorded operation counts - t.Setenv("XDG_STATE_HOME", t.TempDir()) - var payload telemetry.SendTelemetryPayload - service := telemetry.NewService(func(p telemetry.SendTelemetryPayload) { - payload = p - }) - event := Begin(service, "gh issue comment", 2) - event.RecordOperations(UploadResult{AppendOperations: 2}) - - // When a later result replaces those counts and the invocation finishes - event.RecordOperations(UploadResult{Uploaded: 1, ReplaceOperations: 1}) - service.Finish() - - // Then the payload contains the latest counts, including a reset to zero - require.Len(t, payload.Events, 1) - assert.Equal(t, map[string]int64{ - "attach_count": 2, - "append_ops_count": 0, - "replace_ops_count": 1, - }, payload.Events[0].Measures) -} - func TestFlagUserAssets(t *testing.T) { tests := []struct { name string diff --git a/internal/attachments/telemetry.go b/internal/attachments/telemetry.go index 496b1b39f00..3631ff81f6a 100644 --- a/internal/attachments/telemetry.go +++ b/internal/attachments/telemetry.go @@ -8,11 +8,11 @@ type TelemetryEvent struct { pendingEvent ghtelemetry.PendingEvent } -// Begin starts an attachment event at full sampling for the raw supplied count. +// BeginTelemetry starts an attachment event at full sampling for the raw supplied count. // A zero count returns nil without changing sampling. recorder is required; // use a no-op service when telemetry is disabled. // Call immediately before Flag.UserAssets so invalid and over-limit inputs count. -func Begin(recorder ghtelemetry.InvocationRecorder, command string, attachCount int) *TelemetryEvent { +func BeginTelemetry(recorder ghtelemetry.InvocationRecorder, command string, attachCount int) *TelemetryEvent { if attachCount == 0 { return nil } diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go index a4ac43677f5..4086fc765b3 100644 --- a/internal/telemetry/fake.go +++ b/internal/telemetry/fake.go @@ -11,44 +11,30 @@ var ( _ ghtelemetry.InvocationRecorder = (*InvocationRecorderSpy)(nil) ) -// EventRecorderSpy captures complete events immediately. Finish includes pending -// events in recording order and freezes their handles without requiring policy methods. +// EventRecorderSpy captures recorded and pending events in recording order. type EventRecorderSpy struct { - Events []ghtelemetry.Event - events []*ghtelemetry.Event - finished bool + events []*ghtelemetry.Event } -// Record captures a complete event without waiting for Finish. +// Record captures a complete event. func (r *EventRecorderSpy) Record(event ghtelemetry.Event) { - if r.finished { - return - } r.Begin(event) - r.Events = append(r.Events, cloneEvent(event)) } // Begin captures initial facts and returns a handle for subsequent updates. func (r *EventRecorderSpy) Begin(event ghtelemetry.Event) ghtelemetry.PendingEvent { - if r.finished { - return noOpPendingEvent{} - } event = cloneEvent(event) r.events = append(r.events, &event) - return &pendingEventSpy{recorder: r, event: &event} + return &pendingEventSpy{event: &event} } -// Finish snapshots recorded facts into Events once. -func (r *EventRecorderSpy) Finish() { - if r.finished { - return - } - r.finished = true - r.Events = nil +// Events returns copies of all captured events with their latest updates. +func (r *EventRecorderSpy) Events() []ghtelemetry.Event { + var events []ghtelemetry.Event for _, event := range r.events { - r.Events = append(r.Events, cloneEvent(*event)) + events = append(events, cloneEvent(*event)) } - r.events = nil + return events } // InvocationRecorderSpy adds invocation sampling to EventRecorderSpy. @@ -63,14 +49,10 @@ func (r *InvocationRecorderSpy) SetSampleRate(rate int) { } type pendingEventSpy struct { - recorder *EventRecorderSpy - event *ghtelemetry.Event + event *ghtelemetry.Event } func (p *pendingEventSpy) UpsertDimensions(dimensions ghtelemetry.Dimensions) { - if p.recorder.finished { - return - } if p.event.Dimensions == nil { p.event.Dimensions = make(ghtelemetry.Dimensions) } @@ -78,9 +60,6 @@ func (p *pendingEventSpy) UpsertDimensions(dimensions ghtelemetry.Dimensions) { } func (p *pendingEventSpy) UpsertMeasures(measures ghtelemetry.Measures) { - if p.recorder.finished { - return - } if p.event.Measures == nil { p.event.Measures = make(ghtelemetry.Measures) } diff --git a/internal/telemetry/service_test.go b/internal/telemetry/service_test.go index 90ca47d68de..d08bc67a7e3 100644 --- a/internal/telemetry/service_test.go +++ b/internal/telemetry/service_test.go @@ -3,7 +3,6 @@ package telemetry import ( "sync" "testing" - "testing/synctest" "time" "github.com/cli/cli/v2/internal/gh/ghtelemetry" @@ -11,82 +10,46 @@ import ( "github.com/stretchr/testify/require" ) -func TestServiceCopiesFactsAtTheirRecordingTime(t *testing.T) { +func TestServiceCopiesRecordedEvents(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) - synctest.Test(t, func(t *testing.T) { - // Given a producer that reuses its event and update maps - var payloads []SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - facts := ghtelemetry.Event{ - Type: "command_invocation", - Dimensions: ghtelemetry.Dimensions{"command": "gh issue create", "flags": ""}, - Measures: ghtelemetry.Measures{"count": 1, "total": 2}, - } - startedAt := time.Now() - pending := service.Begin(facts) - time.Sleep(time.Second) - facts.Type = "completed_step" - service.Record(facts) - - // When the producer updates pending facts and later reuses those maps - facts.Dimensions["command"] = "unrelated" - facts.Measures["count"] = 99 - dimensions := ghtelemetry.Dimensions{"flags": "attach"} - measures := ghtelemetry.Measures{"count": 2} - pending.UpsertDimensions(dimensions) - pending.UpsertMeasures(measures) - dimensions["flags"] = "unrelated" - measures["count"] = 99 - time.Sleep(time.Second) - require.Empty(t, payloads, "recording and updating facts must not send them before Finish") - service.Finish() + // Given a recorded event + var payload SendTelemetryPayload + service := NewService(func(p SendTelemetryPayload) { payload = p }) + event := ghtelemetry.Event{ + Type: "command_invocation", + Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, + Measures: ghtelemetry.Measures{"count": 1}, + } + service.Record(event) - // Then event order, original timestamps, and independently owned facts survive - require.Len(t, payloads, 1) - require.Len(t, payloads[0].Events, 2) - first, second := payloads[0].Events[0], payloads[0].Events[1] - assert.Equal(t, "command_invocation", first.Type) - assert.Equal(t, "gh issue create", first.Dimensions["command"]) - assert.Equal(t, "attach", first.Dimensions["flags"]) - assert.Equal(t, int64(2), first.Measures["count"]) - assert.Equal(t, int64(2), first.Measures["total"]) - assert.Equal(t, startedAt.UTC().Format("2006-01-02T15:04:05.000Z"), first.Dimensions["timestamp"]) - assert.Equal(t, "completed_step", second.Type) - assert.Equal(t, "gh issue create", second.Dimensions["command"]) - assert.Empty(t, second.Dimensions["flags"]) - assert.Equal(t, int64(1), second.Measures["count"]) - assert.Equal(t, startedAt.Add(time.Second).UTC().Format("2006-01-02T15:04:05.000Z"), second.Dimensions["timestamp"]) - }) + // When the original maps are mutated before delivery + event.Dimensions["command"] = "changed" + event.Measures["count"] = 2 + service.Finish() + + // Then the recorded values are unchanged + require.Len(t, payload.Events, 1) + assert.Equal(t, "gh issue create", payload.Events[0].Dimensions["command"]) + assert.Equal(t, int64(1), payload.Events[0].Measures["count"]) } func TestServicePromotesAllEventsBeforeCompletion(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) - // Given a command that discovers its full-sampling policy after recording facts + // Given events recorded under a sampling policy that would exclude them var payload SendTelemetryPayload - svc := NewService(func(p SendTelemetryPayload) { payload = p }, - WithSampleRate(1), - WithAdditionalCommonDimensions(ghtelemetry.Dimensions{"sample_rate": "1"}), - ) - // Promotion must rescue an invocation that would otherwise be excluded. + svc := NewService(func(p SendTelemetryPayload) { payload = p }, WithSampleRate(1)) svc.(*service).sampleBucket = 99 svc.Record(ghtelemetry.Event{Type: "completed_step"}) - pending := svc.Begin(ghtelemetry.Event{Type: "attachment_invocation"}) + svc.Begin(ghtelemetry.Event{Type: "attachment_invocation"}) - // When attachment usage promotes the invocation before it finishes + // When sampling is promoted before completion svc.SetSampleRate(ghtelemetry.SAMPLE_ALL) - pending.UpsertMeasures(ghtelemetry.Measures{"attach_count": 2}) svc.Finish() - // Then immediate and pending events share the promoted sampling policy - require.Len(t, payload.Events, 2) - assert.Equal(t, "completed_step", payload.Events[0].Type) - assert.Equal(t, "attachment_invocation", payload.Events[1].Type) - assert.Equal(t, "100", payload.Events[0].Dimensions["sample_rate"]) - assert.Equal(t, "100", payload.Events[1].Dimensions["sample_rate"]) - assert.Equal(t, payload.Events[0].Dimensions["invocation_id"], payload.Events[1].Dimensions["invocation_id"]) - assert.Equal(t, int64(2), payload.Events[1].Measures["attach_count"]) + // Then both events are delivered + assert.Len(t, payload.Events, 2) } func TestServiceDisablingOverridesPromotedPendingEvents(t *testing.T) { @@ -110,82 +73,56 @@ func TestServiceDisablingOverridesPromotedPendingEvents(t *testing.T) { assert.Empty(t, payloads[0].Events) } -func TestServiceCompletionCannotBeReopened(t *testing.T) { +func TestServiceFinishDeliversOnce(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) - // Given a completed invocation with one pending event - var payloads []SendTelemetryPayload - service := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }) - pending := service.Begin(ghtelemetry.Event{ - Type: "command_invocation", - Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, - }) - service.Finish() + // Given an invocation with a recorded event + deliveries := 0 + service := NewService(func(SendTelemetryPayload) { deliveries++ }) + service.Record(ghtelemetry.Event{Type: "test"}) - // When cleanup repeats or code holding an old handle attempts further recording - pending.UpsertDimensions(ghtelemetry.Dimensions{"command": "changed"}) - service.Record(ghtelemetry.Event{Type: "too_late"}) - late := service.Begin(ghtelemetry.Event{Type: "also_too_late"}) - late.UpsertDimensions(ghtelemetry.Dimensions{"command": "changed"}) - late.UpsertMeasures(ghtelemetry.Measures{"count": 1}) + // When completion is called twice service.Finish() service.Finish() - // Then the original snapshot is delivered exactly once - require.Len(t, payloads, 1) - require.Len(t, payloads[0].Events, 1) - assert.Equal(t, "command_invocation", payloads[0].Events[0].Type) - assert.Equal(t, "gh issue create", payloads[0].Events[0].Dimensions["command"]) + // Then the payload is delivered only once + assert.Equal(t, 1, deliveries) } -func TestServiceCleanupDuringSendCannotChangePayload(t *testing.T) { +func TestServiceRecordingDoesNotWaitForDelivery(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) - // Given a sender that is still working when command cleanup runs + // Given an invocation whose delivery is blocked sendStarted := make(chan struct{}) allowSend := make(chan struct{}) - var payloads []SendTelemetryPayload - service := NewService(func(payload SendTelemetryPayload) { + service := NewService(func(SendTelemetryPayload) { close(sendStarted) <-allowSend - payloads = append(payloads, payload) }) - pending := service.Begin(ghtelemetry.Event{ - Type: "attachment_invocation", - Dimensions: ghtelemetry.Dimensions{"command": "gh issue create"}, - Measures: ghtelemetry.Measures{"append_ops_count": 1}, - }) - - // When cleanup updates an old handle and repeats completion during sending - done := make(chan struct{}) + service.Record(ghtelemetry.Event{Type: "test"}) + deliveryDone := make(chan struct{}) go func() { service.Finish() - close(done) + close(deliveryDone) }() <-sendStarted - cleanupDone := make(chan struct{}) + + // When another recording is attempted + recordingDone := make(chan struct{}) go func() { - pending.UpsertDimensions(ghtelemetry.Dimensions{"command": "changed"}) - pending.UpsertMeasures(ghtelemetry.Measures{"append_ops_count": 99}) service.Record(ghtelemetry.Event{Type: "too_late"}) - service.Finish() - close(cleanupDone) + close(recordingDone) }() - // Then cleanup does not block on the sender or change its single snapshot + // Then recording returns without waiting for delivery select { - case <-cleanupDone: + case <-recordingDone: case <-time.After(5 * time.Second): - t.Error("command cleanup blocked while telemetry was being sent") + t.Error("recording blocked while telemetry was being sent") } close(allowSend) - <-done - <-cleanupDone - require.Len(t, payloads, 1) - require.Len(t, payloads[0].Events, 1) - assert.Equal(t, "attachment_invocation", payloads[0].Events[0].Type) - assert.Equal(t, "gh issue create", payloads[0].Events[0].Dimensions["command"]) - assert.Equal(t, int64(1), payloads[0].Events[0].Measures["append_ops_count"]) + <-deliveryDone + <-recordingDone } func TestServiceCollectsConcurrentFacts(t *testing.T) { diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 9b2c4b67d9a..352b6c02e25 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -475,21 +475,13 @@ func TestServiceSampling(t *testing.T) { svc := NewService(func(p SendTelemetryPayload) { payloads = append(payloads, p) }, WithSampleRate(tt.sampleRate)) // Fix the random bucket so sampling boundaries can be asserted through delivery. svc.(*service).sampleBucket = tt.sampleBucket + svc.Record(ghtelemetry.Event{Type: "test"}) // When the invocation finishes - svc.Record(ghtelemetry.Event{Type: "completed_step"}) - pending := svc.Begin(ghtelemetry.Event{Type: "attachment_invocation"}) - pending.UpsertMeasures(ghtelemetry.Measures{"attach_count": 2}) svc.Finish() - // Then sampling includes or excludes immediate and pending events together - require.Len(t, payloads, tt.wantPayloads) - for _, payload := range payloads { - require.Len(t, payload.Events, 2) - assert.Equal(t, "completed_step", payload.Events[0].Type) - assert.Equal(t, "attachment_invocation", payload.Events[1].Type) - assert.Equal(t, int64(2), payload.Events[1].Measures["attach_count"]) - } + // Then the sampling policy determines whether a payload is delivered + assert.Len(t, payloads, tt.wantPayloads) }) } } diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index 254a872fd29..8db65cdf122 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -452,11 +452,10 @@ func TestNewCmdComment(t *testing.T) { cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) - // When the command executes and telemetry is completed + // When the command executes _, err = cmd.ExecuteC() - recorder.Finish() // Then telemetry starts only if execution reaches attachment validation - assert.Equal(t, tt.wantEvents, recorder.Events) + assert.Equal(t, tt.wantEvents, recorder.Events()) assert.Equal(t, tt.wantSampleRate, recorder.LastSampleRate) if tt.wantsErr { require.Error(t, err) @@ -494,6 +493,8 @@ func TestNewCmdComment(t *testing.T) { func TestNewCmdCommentRecordsRejectedAttachmentCount(t *testing.T) { // Given 51 attachment values and a normally sampled telemetry service + // NewService reads or creates a device-id file, so isolate it from the user's state. + // An injectable device-ID lookup could avoid this process-wide override and allow parallel tests. t.Setenv("XDG_STATE_HOME", t.TempDir()) ios, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ @@ -557,13 +558,12 @@ func TestNewCmdCommentSkipsAttachmentsOnPersistentPreRunError(t *testing.T) { root.AddCommand(cmd) root.SetArgs([]string{"comment", "1", "--attach", "./shot.png"}) - // When authentication fails and telemetry is completed + // When authentication fails _, err := root.ExecuteC() - recorder.Finish() // Then attachment validation has not begun, so no event or sampling promotion occurs require.EqualError(t, err, "authentication failed") - assert.Empty(t, recorder.Events) + assert.Empty(t, recorder.Events()) assert.Zero(t, recorder.LastSampleRate) } diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index de1c012b7ce..d4d9a806221 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -165,7 +165,7 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, return err } - opts.AttachEvent = attachments.Begin(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) + opts.AttachEvent = attachments.BeginTelemetry(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 725599c04b5..813e9e6a8ce 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -345,11 +345,10 @@ func TestNewCmdCreate(t *testing.T) { cmd.SetArgs(args) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) - // When the command executes and telemetry is completed + // When the command executes _, err = cmd.ExecuteC() - recorder.Finish() // Then telemetry starts only if execution reaches attachment validation - assert.Equal(t, tt.wantEvents, recorder.Events) + assert.Equal(t, tt.wantEvents, recorder.Events()) assert.Equal(t, tt.wantSampleRate, recorder.LastSampleRate) if tt.wantsErr { require.Error(t, err) @@ -1436,7 +1435,7 @@ func Test_createRun(t *testing.T) { // Given a pending event when operation counts are under test attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - opts.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(tt.attach)) + opts.AttachEvent = attachments.BeginTelemetry(attachmentRecorder, "gh test", len(tt.attach)) } opts.Config = func() (gh.Config, error) { cfg := tt.config @@ -1450,8 +1449,7 @@ func Test_createRun(t *testing.T) { err := createRun(opts) if tt.wantOperations != nil { // Then telemetry retains completed operations, including partial results - attachmentRecorder.Finish() - attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) + attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events(), len(tt.attach), *tt.wantOperations) } if tt.wantsErr == "" { require.NoError(t, err) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 0ae1f08f65b..a98b64dbffa 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -212,7 +212,7 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, ru opts.Editable.IssueType.Edited = true } - opts.AttachEvent = attachments.Begin(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) + opts.AttachEvent = attachments.BeginTelemetry(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 8763691d59d..893c3f296e5 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -500,11 +500,10 @@ func TestNewCmdEdit(t *testing.T) { cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) - // When the command executes and telemetry is completed + // When the command executes _, err = cmd.ExecuteC() - recorder.Finish() // Then telemetry starts only if execution reaches attachment validation - assert.Equal(t, tt.wantEvents, recorder.Events) + assert.Equal(t, tt.wantEvents, recorder.Events()) assert.Equal(t, tt.wantSampleRate, recorder.LastSampleRate) if tt.wantsErr { require.Error(t, err) @@ -1776,7 +1775,7 @@ func Test_editRun(t *testing.T) { // Given a pending event when operation counts are under test attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - tt.input.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(tt.attach)) + tt.input.AttachEvent = attachments.BeginTelemetry(attachmentRecorder, "gh test", len(tt.attach)) } hostTokens := tt.hostTokens @@ -1791,8 +1790,7 @@ func Test_editRun(t *testing.T) { err := editRun(tt.input) if tt.wantOperations != nil { // Then telemetry retains completed operations, including partial results - attachmentRecorder.Finish() - attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) + attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events(), len(tt.attach), *tt.wantOperations) } if tt.wantErr { require.Error(t, err) diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go index d3c3c3a30e6..cecde19aa94 100644 --- a/pkg/cmd/pr/comment/comment_test.go +++ b/pkg/cmd/pr/comment/comment_test.go @@ -473,11 +473,10 @@ func TestNewCmdComment(t *testing.T) { cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) - // When the command executes and telemetry is completed + // When the command executes _, err = cmd.ExecuteC() - recorder.Finish() // Then telemetry starts only if execution reaches attachment validation - assert.Equal(t, tt.wantEvents, recorder.Events) + assert.Equal(t, tt.wantEvents, recorder.Events()) assert.Equal(t, tt.wantSampleRate, recorder.LastSampleRate) if tt.wantsErr { require.Error(t, err) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 533d8dd5fa5..794481d82ff 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -369,7 +369,7 @@ func NewCmdCreate(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, return err } - opts.AttachEvent = attachments.Begin(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) + opts.AttachEvent = attachments.BeginTelemetry(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 233eb78ed43..ddcae53e0bf 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -368,11 +368,10 @@ func TestNewCmdCreate(t *testing.T) { cmd.SetArgs(args) cmd.SetOut(stderr) cmd.SetErr(stderr) - // When the command executes and telemetry is completed + // When the command executes _, err = cmd.ExecuteC() - recorder.Finish() // Then telemetry starts only if execution reaches attachment validation - assert.Equal(t, tt.wantEvents, recorder.Events) + assert.Equal(t, tt.wantEvents, recorder.Events()) assert.Equal(t, tt.wantSampleRate, recorder.LastSampleRate) if tt.wantsErr { if tt.wantsErrMsg != "" { @@ -2061,7 +2060,7 @@ func Test_createRun(t *testing.T) { // Given a pending event when operation counts are under test attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - opts.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(opts.Assets)) + opts.AttachEvent = attachments.BeginTelemetry(attachmentRecorder, "gh test", len(opts.Assets)) } // All tests in this function use github.com behavior @@ -2075,8 +2074,7 @@ func Test_createRun(t *testing.T) { err := createRun(&opts) if tt.wantOperations != nil { // Then telemetry retains completed operations, including partial results - attachmentRecorder.Finish() - attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(opts.Assets), *tt.wantOperations) + attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events(), len(opts.Assets), *tt.wantOperations) } output := &test.CmdOut{ OutBuf: stdout, diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index c226ed8f535..a72ff954525 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -221,7 +221,7 @@ func NewCmdEdit(f *cmdutil.Factory, telemetry ghtelemetry.InvocationRecorder, ru } var err error - opts.AttachEvent = attachments.Begin(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) + opts.AttachEvent = attachments.BeginTelemetry(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index aac43617985..dd7c6524173 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -395,11 +395,10 @@ func TestNewCmdEdit(t *testing.T) { cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) - // When the command executes and telemetry is completed + // When the command executes _, err = cmd.ExecuteC() - recorder.Finish() // Then telemetry starts only if execution reaches attachment validation - assert.Equal(t, tt.wantEvents, recorder.Events) + assert.Equal(t, tt.wantEvents, recorder.Events()) assert.Equal(t, tt.wantSampleRate, recorder.LastSampleRate) if tt.wantsErr { require.Error(t, err) @@ -1700,7 +1699,7 @@ func Test_editRun(t *testing.T) { // Given a pending event when operation counts are under test attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - tt.input.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(tt.attach)) + tt.input.AttachEvent = attachments.BeginTelemetry(attachmentRecorder, "gh test", len(tt.attach)) } // The host comes from the pull request the row's finder returns, so @@ -1724,8 +1723,7 @@ func Test_editRun(t *testing.T) { err := editRun(tt.input) if tt.wantOperations != nil { // Then telemetry retains completed operations, including partial results - attachmentRecorder.Finish() - attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) + attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events(), len(tt.attach), *tt.wantOperations) } if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go index b323d2e0a43..43e2e0621f6 100644 --- a/pkg/cmd/pr/shared/commentable.go +++ b/pkg/cmd/pr/shared/commentable.go @@ -106,7 +106,7 @@ func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions, telemetry g } var err error - opts.AttachEvent = attachments.Begin(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) + opts.AttachEvent = attachments.BeginTelemetry(telemetry, cmd.CommandPath(), opts.AttachFlag.Count()) opts.Assets, err = opts.AttachFlag.UserAssets() if err != nil { return err diff --git a/pkg/cmd/pr/shared/commentable_test.go b/pkg/cmd/pr/shared/commentable_test.go index 725939c1262..3b559a87985 100644 --- a/pkg/cmd/pr/shared/commentable_test.go +++ b/pkg/cmd/pr/shared/commentable_test.go @@ -446,7 +446,7 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { // Given a pending event when operation counts are under test attachmentRecorder := &telemetry.InvocationRecorderSpy{} if tt.wantOperations != nil { - opts.AttachEvent = attachments.Begin(attachmentRecorder, "gh test", len(tt.attach)) + opts.AttachEvent = attachments.BeginTelemetry(attachmentRecorder, "gh test", len(tt.attach)) } host := tt.host @@ -490,8 +490,7 @@ func TestCommentableRunUploadsAndWritesBodies(t *testing.T) { err := CommentableRun(&opts) if tt.wantOperations != nil { // Then telemetry retains completed operations, including partial results - attachmentRecorder.Finish() - attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events, len(tt.attach), *tt.wantOperations) + attachments.AssertTestTelemetryEvents(t, attachmentRecorder.Events(), len(tt.attach), *tt.wantOperations) } if tt.wantErr != "" { diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index e04b74aa197..4f488578b1d 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -2624,8 +2624,9 @@ func TestInstallRun_TelemetryVisibility(t *testing.T) { }) require.NoError(t, err) - require.Len(t, recorder.Events, 1) - event := recorder.Events[0] + events := recorder.Events() + require.Len(t, events, 1) + event := events[0] assert.Equal(t, "skill_install", event.Type) assert.NotEmpty(t, event.Dimensions["agent_hosts"], "agent_hosts should always be present") @@ -2720,8 +2721,9 @@ func TestInstallRun_TelemetryMultipleSkills(t *testing.T) { }) require.NoError(t, err) - require.Len(t, recorder.Events, 1) - event := recorder.Events[0] + events := recorder.Events() + require.Len(t, events, 1) + event := events[0] assert.Equal(t, "skill_install", event.Type) assert.Equal(t, "public", event.Dimensions["repo_visibility"]) diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go index ad5069e9d32..9eda58ebeaa 100644 --- a/pkg/cmd/skills/list/list_test.go +++ b/pkg/cmd/skills/list/list_test.go @@ -145,8 +145,9 @@ func TestListRun(t *testing.T) { }, wantStdout: "git-commit\tcursor\tproject\tmonalisa/skills-repo\n", verify: func(t *testing.T, stdout string, spy *telemetry.EventRecorderSpy) { - require.Len(t, spy.Events, 1) - event := spy.Events[0] + events := spy.Events() + require.Len(t, events, 1) + event := events[0] assert.Equal(t, "skill_list", event.Type) assert.Equal(t, "cursor", event.Dimensions["agent_hosts"]) assert.Equal(t, "project", event.Dimensions["scope"]) @@ -182,7 +183,7 @@ func TestListRun(t *testing.T) { } ]`, filepath.Join("HOME", ".copilot", "skills", "code-review")), verify: func(t *testing.T, stdout string, spy *telemetry.EventRecorderSpy) { - assert.Equal(t, "json", spy.Events[0].Dimensions["format"]) + assert.Equal(t, "json", spy.Events()[0].Dimensions["format"]) }, }, { diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 2142b29c0ae..ec73168ecca 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -976,8 +976,9 @@ func TestPreviewRun_InteractiveTelemetryCapturesSelectedSkillName(t *testing.T) 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] + events := recorder.Events() + require.Len(t, events, 1) + event := 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") } @@ -1086,8 +1087,9 @@ func TestPreviewRun_TelemetryVisibility(t *testing.T) { err := previewRun(opts) require.NoError(t, err) - require.Len(t, recorder.Events, 1) - event := recorder.Events[0] + events := recorder.Events() + require.Len(t, events, 1) + event := events[0] assert.Equal(t, "skill_preview", event.Type) // skill_host_type is always recorded (categorized, no raw hostname for enterprise/tenancy). diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index 07b7d658794..d2983f5da7a 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -636,9 +636,10 @@ func TestSearchRun_TelemetryRecordsInstallFromResults(t *testing.T) { // 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) + events := recorder.Events() + require.Len(t, events, 1) - installEvent := recorder.Events[0] + installEvent := 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"], diff --git a/pkg/cmdutil/telemetry_test.go b/pkg/cmdutil/telemetry_test.go index 9faccd4bb33..f0789c756af 100644 --- a/pkg/cmdutil/telemetry_test.go +++ b/pkg/cmdutil/telemetry_test.go @@ -30,16 +30,16 @@ func TestRecordTelemetry(t *testing.T) { cmd.SetArgs([]string{"--web"}) cmdutil.RecordTelemetry(cmd, recorder) - // When Cobra rejects its arguments and the invocation finishes + // When Cobra rejects its arguments _, err := cmd.ExecuteC() - recorder.Finish() // Then the failure is preserved and the command is still recorded require.EqualError(t, err, "accepts 1 arg(s), received 0") - require.Len(t, recorder.Events, 1) - assert.Equal(t, "command_invocation", recorder.Events[0].Type) - assert.Equal(t, "list", recorder.Events[0].Dimensions["command"]) - assert.Equal(t, "web", recorder.Events[0].Dimensions["flags"]) + events := recorder.Events() + require.Len(t, events, 1) + assert.Equal(t, "command_invocation", events[0].Type) + assert.Equal(t, "list", events[0].Dimensions["command"]) + assert.Equal(t, "web", events[0].Dimensions["flags"]) }) tests := []struct { @@ -78,18 +78,18 @@ func TestRecordTelemetry(t *testing.T) { root.SetArgs(append([]string{"pr", "list"}, tt.args...)) cmdutil.RecordTelemetry(cmd, recorder) - // When Cobra executes the command and the invocation finishes + // When Cobra executes the command _, err := root.ExecuteC() - recorder.Finish() // Then only the command path and explicitly supplied flag names are recorded require.NoError(t, err) - require.Len(t, recorder.Events, 1) - assert.Equal(t, "command_invocation", recorder.Events[0].Type) + events := recorder.Events() + require.Len(t, events, 1) + assert.Equal(t, "command_invocation", events[0].Type) assert.Equal(t, ghtelemetry.Dimensions{ "command": "gh pr list", "flags": tt.flags, - }, recorder.Events[0].Dimensions) + }, events[0].Dimensions) }) } @@ -109,14 +109,13 @@ func TestRecordTelemetry(t *testing.T) { cmd.SetArgs([]string{}) cmdutil.RecordTelemetry(cmd, recorder) - // When Cobra executes the command and the invocation finishes + // When Cobra executes the command _, err := cmd.ExecuteC() - recorder.Finish() // Then its original behavior is retained without telemetry require.NoError(t, err) assert.Equal(t, "command output\n", output.String()) - assert.Empty(t, recorder.Events) + assert.Empty(t, recorder.Events()) }) t.Run("records flags parsed during RunE even when execution fails", func(t *testing.T) { @@ -142,16 +141,16 @@ func TestRecordTelemetry(t *testing.T) { cmd.SetArgs([]string{"--remove"}) cmdutil.RecordTelemetry(cmd, recorder) - // When Cobra executes the command and the invocation finishes + // When Cobra executes the command _, err := cmd.ExecuteC() - recorder.Finish() // Then the error is preserved and the late-parsed flag is recorded require.ErrorIs(t, err, expectedErr) - require.Len(t, recorder.Events, 1) - assert.Equal(t, "command_invocation", recorder.Events[0].Type) - assert.Equal(t, "copilot", recorder.Events[0].Dimensions["command"]) - assert.Equal(t, "remove", recorder.Events[0].Dimensions["flags"]) + events := recorder.Events() + require.Len(t, events, 1) + assert.Equal(t, "command_invocation", events[0].Type) + assert.Equal(t, "copilot", events[0].Dimensions["command"]) + assert.Equal(t, "remove", events[0].Dimensions["flags"]) }) t.Run("records commands rejected by a parent persistent pre-run", func(t *testing.T) { @@ -175,16 +174,16 @@ func TestRecordTelemetry(t *testing.T) { root.SetArgs([]string{"list", "--web"}) cmdutil.RecordTelemetry(cmd, recorder) - // When Cobra rejects the command and the invocation finishes + // When Cobra rejects the command _, err := root.ExecuteC() - recorder.Finish() // Then the parent error is preserved and the attempted command is recorded require.ErrorIs(t, err, expectedErr) - require.Len(t, recorder.Events, 1) - assert.Equal(t, "command_invocation", recorder.Events[0].Type) - assert.Equal(t, "gh list", recorder.Events[0].Dimensions["command"]) - assert.Equal(t, "web", recorder.Events[0].Dimensions["flags"]) + events := recorder.Events() + require.Len(t, events, 1) + assert.Equal(t, "command_invocation", events[0].Type) + assert.Equal(t, "gh list", events[0].Dimensions["command"]) + assert.Equal(t, "web", events[0].Dimensions["flags"]) }) t.Run("records commands rejected by their pre-run", func(t *testing.T) { @@ -203,15 +202,15 @@ func TestRecordTelemetry(t *testing.T) { cmd.SetArgs([]string{}) cmdutil.RecordTelemetry(cmd, recorder) - // When Cobra rejects the command and the invocation finishes + // When Cobra rejects the command _, err := cmd.ExecuteC() - recorder.Finish() // Then the validation error is preserved and the attempted command is recorded require.ErrorIs(t, err, expectedErr) - require.Len(t, recorder.Events, 1) - assert.Equal(t, "command_invocation", recorder.Events[0].Type) - assert.Equal(t, "list", recorder.Events[0].Dimensions["command"]) + events := recorder.Events() + require.Len(t, events, 1) + assert.Equal(t, "command_invocation", events[0].Type) + assert.Equal(t, "list", events[0].Dimensions["command"]) }) t.Run("skips commands with telemetry disabled", func(t *testing.T) { @@ -227,13 +226,12 @@ func TestRecordTelemetry(t *testing.T) { cmdutil.DisableTelemetry(cmd) cmdutil.RecordTelemetry(cmd, recorder) - // When Cobra executes the command and the invocation finishes + // When Cobra executes the command _, err := cmd.ExecuteC() - recorder.Finish() // Then the command succeeds without recording telemetry require.NoError(t, err) - assert.Empty(t, recorder.Events, "telemetry should not be recorded for disabled commands") + assert.Empty(t, recorder.Events(), "telemetry should not be recorded for disabled commands") }) } @@ -257,13 +255,13 @@ func TestRecordTelemetryForSubcommands(t *testing.T) { root.SetArgs([]string{"pr", "list"}) cmdutil.RecordTelemetryForSubcommands(root, recorder) - // When Cobra executes a nested command and the invocation finishes + // When Cobra executes a nested command _, err := root.ExecuteC() - recorder.Finish() // Then only the invoked descendant is recorded require.NoError(t, err) - 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"]) + events := recorder.Events() + require.Len(t, events, 1) + assert.Equal(t, "command_invocation", events[0].Type) + assert.Equal(t, "gh pr list", events[0].Dimensions["command"]) } From 1ba640d1c525a2dfc5e60071ffa9c3f948e644d1 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 9 Sep 2026 11:50:50 +0200 Subject: [PATCH 16/22] clarify attachment tests and telemetry state Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: c95db6c5-fc17-4880-915c-5d2c4b938d49 --- internal/attachments/flags_test.go | 37 ++++++++++++++++++++++++------ internal/telemetry/telemetry.go | 20 ++++++++-------- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/internal/attachments/flags_test.go b/internal/attachments/flags_test.go index f125886c90c..8bb6b798b00 100644 --- a/internal/attachments/flags_test.go +++ b/internal/attachments/flags_test.go @@ -269,11 +269,6 @@ func TestFlagUserAssets(t *testing.T) { input: `--attach ./before,after.png`, wantPaths: []string{"./before,after.png"}, }, - { - name: "keeps order for distinct files with identical contents", - input: "--attach './b.png#Second' --attach ./a.png --attach ./c.mp4", - wantPaths: []string{"./b.png", "./a.png", "./c.mp4"}, - }, { name: "the same file twice", input: "--attach ./a.png --attach './a.png#Another caption'", @@ -322,8 +317,6 @@ func TestFlagUserAssets(t *testing.T) { "shot#dark.png#first.png", "caption.png", "a.png", - "b.png", - "c.mp4", "before,after.png", "notes.txt", } { @@ -364,6 +357,36 @@ func TestFlagUserAssets(t *testing.T) { }) } + t.Run("preserves input order", func(t *testing.T) { + // Given two files with different contents + t.Chdir(t.TempDir()) + require.NoError(t, os.WriteFile("a.png", []byte("first"), 0o600)) + require.NoError(t, os.WriteFile("b.png", []byte("second"), 0o600)) + + // When files are supplied in non-alphabetical order + resolved, err := assetsFromArgs(t, "./b.png", "./a.png") + + // Then the resolved files retain that order + require.NoError(t, err) + require.Len(t, resolved, 2) + assert.Equal(t, []string{"./b.png", "./a.png"}, []string{resolved[0].Path(), resolved[1].Path()}) + }) + + t.Run("allows distinct files with identical contents", func(t *testing.T) { + // Given two separate files containing the same bytes + t.Chdir(t.TempDir()) + require.NoError(t, os.WriteFile("a.png", []byte("same contents"), 0o600)) + require.NoError(t, os.WriteFile("b.png", []byte("same contents"), 0o600)) + + // When both files are supplied + resolved, err := assetsFromArgs(t, "./a.png", "./b.png") + + // Then both files are accepted + require.NoError(t, err) + require.Len(t, resolved, 2) + assert.ElementsMatch(t, []string{"./a.png", "./b.png"}, []string{resolved[0].Path(), resolved[1].Path()}) + }) + t.Run("maximum number of attachments", func(t *testing.T) { names := make([]string, maxAttachments) for i := range names { diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 539bf2f4752..c7509b5a2aa 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -252,9 +252,9 @@ type recordedEvent struct { } type service struct { - mu sync.RWMutex - flush func(payload SendTelemetryPayload) - previouslyCalled bool + mu sync.RWMutex + flush func(payload SendTelemetryPayload) + finished bool commonDimensions ghtelemetry.Dimensions sampleRate int @@ -280,7 +280,7 @@ func (s *service) Record(event ghtelemetry.Event) { s.mu.Lock() defer s.mu.Unlock() - if s.previouslyCalled { + if s.finished { return } s.events = append(s.events, &recordedEvent{event: cloneEvent(event), recordedAt: time.Now()}) @@ -292,7 +292,7 @@ func (s *service) Begin(event ghtelemetry.Event) ghtelemetry.PendingEvent { s.mu.Lock() defer s.mu.Unlock() - if s.previouslyCalled { + if s.finished { return noOpPendingEvent{} } recorded := &recordedEvent{ @@ -309,7 +309,7 @@ func (s *service) SetSampleRate(rate int) { s.mu.Lock() defer s.mu.Unlock() - if s.previouslyCalled { + if s.finished { return } s.sampleRate = rate @@ -321,11 +321,11 @@ func (s *service) SetSampleRate(rate int) { func (s *service) Finish() { s.mu.Lock() - if s.previouslyCalled { + if s.finished { s.mu.Unlock() return } - s.previouslyCalled = true + s.finished = true if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate { s.mu.Unlock() @@ -374,7 +374,7 @@ func (p *pendingEvent) UpsertDimensions(dimensions ghtelemetry.Dimensions) { p.service.mu.Lock() defer p.service.mu.Unlock() - if p.service.previouslyCalled { + if p.service.finished { return } if p.recorded.event.Dimensions == nil { @@ -387,7 +387,7 @@ func (p *pendingEvent) UpsertMeasures(measures ghtelemetry.Measures) { p.service.mu.Lock() defer p.service.mu.Unlock() - if p.service.previouslyCalled { + if p.service.finished { return } if p.recorded.event.Measures == nil { From 6dc60bf60359ae91c4f773231843f677d219d542 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 4 Sep 2026 16:32:05 +0200 Subject: [PATCH 17/22] Filter acceptance tests by token capability Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f67f4129-93de-45b1-a68d-702334aa7fe9 --- .../skills/writing-acceptance-tests/SKILL.md | 15 +- acceptance/README.md | 11 +- acceptance/acceptance_test.go | 133 +++++++++++++++- acceptance/testdata/api/basic-graphql.txtar | 1 + acceptance/testdata/api/basic-rest.txtar | 1 + .../testdata/auth/auth-login-logout.txtar | 1 + acceptance/testdata/auth/auth-setup-git.txtar | 1 + acceptance/testdata/auth/auth-status.txtar | 1 + acceptance/testdata/auth/auth-token.txtar | 1 + .../discussion/discussion-comment.txtar | 1 + .../discussion/discussion-create.txtar | 1 + .../testdata/discussion/discussion-edit.txtar | 1 + .../testdata/discussion/discussion-list.txtar | 1 + .../testdata/discussion/discussion-view.txtar | 1 + .../testdata/extension/extension-env.txtar | 1 + acceptance/testdata/extension/extension.txtar | 1 + .../gist/gist-create-view-delete.txtar | 1 + .../testdata/gist/gist-edit-rename-list.txtar | 1 + acceptance/testdata/gpg-key/gpg-key.txtar | 1 + ...ssue-comment-edit-last-with-comments.txtar | 1 + ...t-edit-last-without-comments-creates.txtar | 1 + ...nt-edit-last-without-comments-errors.txtar | 1 + .../testdata/issue/issue-comment-new.txtar | 1 + .../testdata/issue/issue-create-basic.txtar | 1 + .../issue-create-edit-with-project.txtar | 1 + .../issue/issue-create-with-metadata.txtar | 1 + .../issue-develop-worktree-cross-repo.txtar | 1 + .../issue/issue-develop-worktree.txtar | 1 + acceptance/testdata/issue/issue-list.txtar | 1 + acceptance/testdata/issue/issue-view.txtar | 1 + .../issue-create-and-edit-issue-type.txtar | 1 + .../issue-create-and-edit-parent.txtar | 1 + .../issue-create-and-edit-relationships.txtar | 1 + .../issues-2.0/issue-edit-sub-issues.txtar | 1 + .../issue-list-filter-by-type.txtar | 1 + .../issue-view-issues-2.0-fields.txtar | 1 + acceptance/testdata/label/label.txtar | 1 + acceptance/testdata/org/org-list.txtar | 1 + .../testdata/pr/pr-checkout-by-number.txtar | 1 + .../pr/pr-checkout-with-url-from-fork.txtar | 1 + .../pr/pr-checkout-worktree-detach.txtar | 1 + .../pr/pr-checkout-worktree-from-fork.txtar | 1 + .../testdata/pr/pr-checkout-worktree.txtar | 1 + acceptance/testdata/pr/pr-checkout.txtar | 1 + .../pr-comment-edit-last-with-comments.txtar | 1 + ...t-edit-last-without-comments-creates.txtar | 1 + ...nt-edit-last-without-comments-errors.txtar | 1 + acceptance/testdata/pr/pr-comment-new.txtar | 1 + acceptance/testdata/pr/pr-create-basic.txtar | 1 + .../pr/pr-create-edit-with-project.txtar | 1 + .../pr-create-from-issue-develop-base.txtar | 1 + .../pr/pr-create-from-manual-merge-base.txtar | 1 + ...mote-from-sha-with-branch-name-slash.txtar | 1 + .../pr-create-guesses-remote-from-sha.txtar | 1 + .../testdata/pr/pr-create-no-local-repo.txtar | 1 + ...h-default-upstream-no-merge-ref-fork.txtar | 1 + ...e-push-default-upstream-no-merge-ref.txtar | 1 + ...te-remote-ref-with-branch-name-slash.txtar | 1 + ...pr-create-respects-branch-pushremote.txtar | 1 + .../pr-create-respects-push-destination.txtar | 1 + ...r-create-respects-remote-pushdefault.txtar | 1 + ...r-create-respects-simple-pushdefault.txtar | 1 + ...te-respects-user-colon-branch-syntax.txtar | 1 + .../testdata/pr/pr-create-with-metadata.txtar | 1 + .../pr-create-without-upstream-config.txtar | 1 + acceptance/testdata/pr/pr-list.txtar | 1 + .../testdata/pr/pr-merge-merge-strategy.txtar | 1 + .../pr/pr-status-respects-cross-org.txtar | 1 + .../testdata/pr/pr-view-outside-repo.txtar | 1 + .../testdata/pr/pr-view-same-org-fork.txtar | 1 + ...ew-status-respects-branch-pushremote.txtar | 1 + ...iew-status-respects-push-destination.txtar | 1 + ...w-status-respects-remote-pushdefault.txtar | 1 + ...w-status-respects-simple-pushdefault.txtar | 1 + acceptance/testdata/pr/pr-view.txtar | 1 + .../project/project-create-delete.txtar | 1 + .../testdata/release/release-create.txtar | 1 + .../testdata/release/release-delete.txtar | 1 + .../testdata/release/release-list.txtar | 1 + .../release/release-upload-download.txtar | 1 + .../testdata/release/release-view.txtar | 1 + .../repo/repo-archive-unarchive.txtar | 1 + acceptance/testdata/repo/repo-autolink.txtar | 1 + acceptance/testdata/repo/repo-clone.txtar | 1 + .../testdata/repo/repo-create-bare.txtar | 1 + .../testdata/repo/repo-create-view.txtar | 1 + acceptance/testdata/repo/repo-delete.txtar | 1 + .../testdata/repo/repo-deploy-key.txtar | 1 + acceptance/testdata/repo/repo-edit.txtar | 1 + acceptance/testdata/repo/repo-fork-sync.txtar | 1 + .../testdata/repo/repo-list-rename.txtar | 1 + acceptance/testdata/repo/repo-read-dir.txtar | 1 + acceptance/testdata/repo/repo-read-file.txtar | 1 + .../repo/repo-rename-transfer-ownership.txtar | 1 + .../testdata/repo/repo-set-default.txtar | 1 + .../testdata/repo/repo-sync-worktree.txtar | 1 + acceptance/testdata/repo/repo-sync.txtar | 1 + acceptance/testdata/ruleset/ruleset.txtar | 1 + .../testdata/search/search-issues.txtar | 1 + .../secret-org-with-selected-visibility.txtar | 1 + acceptance/testdata/secret/secret-org.txtar | 1 + .../testdata/secret/secret-repo-env.txtar | 1 + ...secret-require-remote-disambiguation.txtar | 1 + .../skills/skills-install-force.txtar | 1 + .../skills/skills-install-from-local.txtar | 1 + .../skills/skills-install-invalid-agent.txtar | 1 + .../skills/skills-install-invalid-repo.txtar | 1 + .../skills/skills-install-namespaced.txtar | 1 + .../skills/skills-install-nested-files.txtar | 1 + .../skills-install-nonexistent-skill.txtar | 1 + .../testdata/skills/skills-install-pin.txtar | 1 + .../skills/skills-install-scope.txtar | 1 + .../testdata/skills/skills-install.txtar | 1 + .../skills-preview-noninteractive.txtar | 1 + .../testdata/skills/skills-preview.txtar | 1 + .../skills/skills-publish-dir-remote.txtar | 1 + .../skills/skills-publish-dry-run.txtar | 1 + .../skills/skills-publish-lifecycle.txtar | 1 + .../testdata/skills/skills-search.txtar | 1 + .../skills/skills-update-inplace.txtar | 1 + .../skills/skills-update-noinstalled.txtar | 1 + .../testdata/skills/skills-update.txtar | 1 + acceptance/testdata/ssh-key/ssh-key.txtar | 1 + .../accessibility-dimensions-disabled.txtar | 1 + .../telemetry/accessibility-dimensions.txtar | 1 + .../testdata/telemetry/agent-dimensions.txtar | 1 + .../telemetry/command-invocation.txtar | 1 + .../telemetry/no-telemetry-for-alias.txtar | 1 + .../no-telemetry-for-completion.txtar | 1 + .../no-telemetry-for-extension.txtar | 1 + .../no-telemetry-for-ghes-user.txtar | 1 + .../no-telemetry-for-send-telemetry.txtar | 1 + ...metry-failure-does-not-break-command.txtar | 1 + ...elemetry-for-official-extension-stub.txtar | 1 + .../testdata/variable/variable-org.txtar | 1 + .../testdata/variable/variable-repo-env.txtar | 1 + .../testdata/variable/variable-repo.txtar | 1 + .../testdata/workflow/cache-list-delete.txtar | 1 + .../testdata/workflow/cache-list-empty.txtar | 1 + acceptance/testdata/workflow/run-cancel.txtar | 1 + acceptance/testdata/workflow/run-delete.txtar | 1 + .../testdata/workflow/run-download.txtar | 1 + acceptance/testdata/workflow/run-rerun.txtar | 1 + .../run-view-log-escape-sequences.txtar | 1 + acceptance/testdata/workflow/run-view.txtar | 1 + .../workflow/workflow-enable-disable.txtar | 1 + .../testdata/workflow/workflow-list.txtar | 1 + .../testdata/workflow/workflow-run.txtar | 1 + .../testdata/workflow/workflow-view.txtar | 1 + acceptance/user_capability_test.go | 142 ++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 152 files changed, 440 insertions(+), 13 deletions(-) diff --git a/.github/skills/writing-acceptance-tests/SKILL.md b/.github/skills/writing-acceptance-tests/SKILL.md index c61c5eea597..e93f335c09c 100644 --- a/.github/skills/writing-acceptance-tests/SKILL.md +++ b/.github/skills/writing-acceptance-tests/SKILL.md @@ -11,6 +11,19 @@ creation and cloning without allowing concurrent scripts to interfere. Read `acceptance/README.md` and nearby scripts before editing. Test groups are discovered from `acceptance/testdata//`; do not register them manually. +## Declare token capability + +Every `.txtar` script must start with exactly one capability declaration: + +```txtar +# requires-user-capability: false +``` + +Set the declaration to `true` only when the script requires a user principal, +such as account SSH/GPG keys, personal-account forks, or user membership APIs. +Repository and organization operations supported by an installation token +should use `false`. + ## Choose one repository mode Every script must contain exactly one declaration: @@ -120,7 +133,7 @@ Run metadata checks without live credentials: ```sh go test -tags=acceptance \ - -run '^(TestSelectAcceptanceTestGroups|TestAcceptanceScriptsDeclareFixtureRepository|TestValidateFixtureRepositoryDeclaration|TestFixtureRepositoryManager)$' \ + -run '^(TestSelectAcceptanceTestGroups|TestFilterAcceptanceScripts|TestTokenHasUserCapability|TestAcceptanceScriptsDeclareUserCapabilityRequirement|TestAcceptanceScriptsDeclareFixtureRepository|TestRequiresUserCapabilityForScriptErrors|TestValidateFixtureRepositoryDeclaration|TestFixtureRepositoryManager)$' \ ./acceptance ``` diff --git a/acceptance/README.md b/acceptance/README.md index 2f41d5f73cf..f593b856441 100644 --- a/acceptance/README.md +++ b/acceptance/README.md @@ -24,6 +24,8 @@ The token to use for authenticating with the `GH_ACCEPTANCE_HOST`. This must alr It's recommended to create and use a Legacy PAT for this; Fine-Grained PATs do not offer all the necessary privileges required. You can use an OAuth token provided via `gh auth login --web` and can provide it to the acceptance tests via `GH_ACCEPTANCE_TOKEN=$(gh auth token --hostname )` but this can be a bit confusing and annoying if you `gh auth login` again without `-s` and lose the required scopes. +The test harness infers whether a token authenticates a user from GitHub's documented token prefixes. OAuth (`gho_`), classic PAT (`ghp_`), fine-grained PAT (`github_pat_`), and GitHub App user (`ghu_`) tokens provide user capabilities. GitHub App installation (`ghs_`) tokens do not, so scripts marked `requires-user-capability: true` are skipped. + Managed fixture repositories reduce repository creation by sharing state where tests can safely coexist. @@ -76,7 +78,14 @@ The following custom environment variables are made available to the scripts: #### Script Metadata -Every script must declare exactly one repository fixture mode: +Every script must begin with a structured header comment declaring whether it +needs a token that authenticates a user: + +```txtar +# requires-user-capability: false +``` + +Every script must also declare exactly one repository fixture mode: ```txtar fixture-repo shared REPO diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 65597121b7b..73d0e5192ca 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -645,6 +645,8 @@ func validateAcceptanceScripts(t *testing.T, tsEnv testScriptEnv, groups []strin require.NoError(t, err) for _, file := range candidates { require.NoError(t, validateFixtureRepositoryDeclaration(file)) + _, err := requiresUserCapabilityForScript(file) + require.NoError(t, err) } } } @@ -713,19 +715,128 @@ func TestSelectAcceptanceTestGroups(t *testing.T) { } } +type acceptanceScript struct { + file string + requiresUserCapability bool +} + +func filterAcceptanceScripts(candidates []acceptanceScript, filtered, hasUserCapability bool) ([]string, string, error) { + if filtered && len(candidates) == 0 { + return nil, "no selected script belongs to this command directory", nil + } + + files := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.requiresUserCapability && !hasUserCapability { + if filtered { + return nil, "", fmt.Errorf("%s requires a token that authenticates a user", candidate.file) + } + continue + } + files = append(files, candidate.file) + } + if len(files) == 0 { + return nil, "all scripts require a token that authenticates a user", nil + } + + return files, "", nil +} + +func TestFilterAcceptanceScripts(t *testing.T) { + userOnly := acceptanceScript{file: "user.txtar", requiresUserCapability: true} + compatible := acceptanceScript{file: "installation.txtar"} + + tests := []struct { + name string + candidates []acceptanceScript + filtered bool + hasUserCapability bool + wantFiles []string + wantSkip string + wantErr string + }{ + { + name: "user token keeps all scripts", + candidates: []acceptanceScript{userOnly, compatible}, + hasUserCapability: true, + wantFiles: []string{"user.txtar", "installation.txtar"}, + }, + { + name: "installation token omits user-only scripts", + candidates: []acceptanceScript{userOnly, compatible}, + wantFiles: []string{"installation.txtar"}, + }, + { + name: "all incompatible scripts skip", + candidates: []acceptanceScript{userOnly}, + wantSkip: "all scripts require a token that authenticates a user", + }, + { + name: "empty explicit selection skips", + filtered: true, + wantSkip: "no selected script belongs to this command directory", + }, + { + name: "explicit compatible selection remains", + candidates: []acceptanceScript{compatible}, + filtered: true, + wantFiles: []string{"installation.txtar"}, + }, + { + name: "explicit incompatible selection errors", + candidates: []acceptanceScript{userOnly}, + filtered: true, + wantErr: "user.txtar requires a token that authenticates a user", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + files, skipReason, err := filterAcceptanceScripts(tt.candidates, tt.filtered, tt.hasUserCapability) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantFiles, files) + assert.Equal(t, tt.wantSkip, skipReason) + }) + } +} + func testScriptParamsFor(t *testing.T, tsEnv testScriptEnv, fixtureRepositories *fixtureRepositoryManager, command string) testscript.Params { t.Helper() - candidates, filtered, err := acceptanceScriptCandidates(tsEnv, command) + scriptFiles, filtered, err := acceptanceScriptCandidates(tsEnv, command) if err != nil { t.Fatal(err) } - if filtered && len(candidates) == 0 { - t.Skipf("testdata/%s: no selected script belongs to this command directory", command) + + candidates := make([]acceptanceScript, 0, len(scriptFiles)) + for _, file := range scriptFiles { + requiresUserCapability, err := requiresUserCapabilityForScript(file) + if err != nil { + t.Fatal(err) + } + candidates = append(candidates, acceptanceScript{ + file: file, + requiresUserCapability: requiresUserCapability, + }) + } + + files, skipReason, err := filterAcceptanceScripts(candidates, filtered, tsEnv.hasUserCapability) + if err != nil { + t.Fatal(err) + } + if skipReason != "" { + if filtered { + t.Skipf("testdata/%s: %s", command, skipReason) + } + t.Skip(skipReason) } return testscript.Params{ - Files: candidates, + Files: files, Setup: sharedSetup(tsEnv), Cmds: sharedCmds(tsEnv, fixtureRepositories), RequireExplicitExec: true, @@ -1174,10 +1285,11 @@ func (e missingEnvError) Error() string { } type testScriptEnv struct { - host string - org string - token string - user string + host string + org string + token string + user string + hasUserCapability bool // scripts optionally narrows a run to named scripts within the command // directory being run. Empty means run every script in the directory. @@ -1223,6 +1335,11 @@ func (e *testScriptEnv) fromEnv() error { e.host = envMap["GH_ACCEPTANCE_HOST"] e.org = envMap["GH_ACCEPTANCE_ORG"] e.token = envMap["GH_ACCEPTANCE_TOKEN"] + var err error + e.hasUserCapability, err = tokenHasUserCapability(e.token) + if err != nil { + return err + } e.scripts = parseScriptFilter(os.Getenv("GH_ACCEPTANCE_SCRIPT")) e.preserveWorkDir = os.Getenv("GH_ACCEPTANCE_PRESERVE_WORK_DIR") == "true" diff --git a/acceptance/testdata/api/basic-graphql.txtar b/acceptance/testdata/api/basic-graphql.txtar index 4a0e677c01f..ae430f04156 100644 --- a/acceptance/testdata/api/basic-graphql.txtar +++ b/acceptance/testdata/api/basic-graphql.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/api/basic-rest.txtar b/acceptance/testdata/api/basic-rest.txtar index f25aa9e4d53..c3a678c456c 100644 --- a/acceptance/testdata/api/basic-rest.txtar +++ b/acceptance/testdata/api/basic-rest.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/auth/auth-login-logout.txtar b/acceptance/testdata/auth/auth-login-logout.txtar index 9fbb0274f76..bb12ea1a0a3 100644 --- a/acceptance/testdata/auth/auth-login-logout.txtar +++ b/acceptance/testdata/auth/auth-login-logout.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/auth/auth-setup-git.txtar b/acceptance/testdata/auth/auth-setup-git.txtar index a8f631423f8..690a78ce820 100644 --- a/acceptance/testdata/auth/auth-setup-git.txtar +++ b/acceptance/testdata/auth/auth-setup-git.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/auth/auth-status.txtar b/acceptance/testdata/auth/auth-status.txtar index bddc463d6bc..80af9ef4e0e 100644 --- a/acceptance/testdata/auth/auth-status.txtar +++ b/acceptance/testdata/auth/auth-status.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/auth/auth-token.txtar b/acceptance/testdata/auth/auth-token.txtar index 2efcc6963a7..d3237ddd21e 100644 --- a/acceptance/testdata/auth/auth-token.txtar +++ b/acceptance/testdata/auth/auth-token.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/discussion/discussion-comment.txtar b/acceptance/testdata/discussion/discussion-comment.txtar index 351cad22792..e8c78cfcdec 100644 --- a/acceptance/testdata/discussion/discussion-comment.txtar +++ b/acceptance/testdata/discussion/discussion-comment.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/discussion/discussion-create.txtar b/acceptance/testdata/discussion/discussion-create.txtar index 5df879d2405..1e5fcd39c43 100644 --- a/acceptance/testdata/discussion/discussion-create.txtar +++ b/acceptance/testdata/discussion/discussion-create.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo isolated REPO diff --git a/acceptance/testdata/discussion/discussion-edit.txtar b/acceptance/testdata/discussion/discussion-edit.txtar index fb1487d9d6f..eb4f6d331f5 100644 --- a/acceptance/testdata/discussion/discussion-edit.txtar +++ b/acceptance/testdata/discussion/discussion-edit.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/discussion/discussion-list.txtar b/acceptance/testdata/discussion/discussion-list.txtar index 46a4fd096d4..dab4194cad7 100644 --- a/acceptance/testdata/discussion/discussion-list.txtar +++ b/acceptance/testdata/discussion/discussion-list.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo isolated REPO diff --git a/acceptance/testdata/discussion/discussion-view.txtar b/acceptance/testdata/discussion/discussion-view.txtar index c046b607c26..c0cae547356 100644 --- a/acceptance/testdata/discussion/discussion-view.txtar +++ b/acceptance/testdata/discussion/discussion-view.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo isolated REPO diff --git a/acceptance/testdata/extension/extension-env.txtar b/acceptance/testdata/extension/extension-env.txtar index 23736428303..f0b759ddead 100644 --- a/acceptance/testdata/extension/extension-env.txtar +++ b/acceptance/testdata/extension/extension-env.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Verify that gh tells an extension when it is being run as an extension diff --git a/acceptance/testdata/extension/extension.txtar b/acceptance/testdata/extension/extension.txtar index 9bfdb1b6c8a..2f8b8bff311 100644 --- a/acceptance/testdata/extension/extension.txtar +++ b/acceptance/testdata/extension/extension.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Skip if Bash is not available given script extension [!exec:bash] skip diff --git a/acceptance/testdata/gist/gist-create-view-delete.txtar b/acceptance/testdata/gist/gist-create-view-delete.txtar index b1b4660eb33..23c0ba4cc00 100644 --- a/acceptance/testdata/gist/gist-create-view-delete.txtar +++ b/acceptance/testdata/gist/gist-create-view-delete.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true fixture-repo none diff --git a/acceptance/testdata/gist/gist-edit-rename-list.txtar b/acceptance/testdata/gist/gist-edit-rename-list.txtar index 5a91cb5dee2..a552214bf0f 100644 --- a/acceptance/testdata/gist/gist-edit-rename-list.txtar +++ b/acceptance/testdata/gist/gist-edit-rename-list.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true fixture-repo none diff --git a/acceptance/testdata/gpg-key/gpg-key.txtar b/acceptance/testdata/gpg-key/gpg-key.txtar index 81e3bf9f8b8..f767ec1797f 100644 --- a/acceptance/testdata/gpg-key/gpg-key.txtar +++ b/acceptance/testdata/gpg-key/gpg-key.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true skip 'it modifies the user''s personal GitHub account GPG keys' diff --git a/acceptance/testdata/issue/issue-comment-edit-last-with-comments.txtar b/acceptance/testdata/issue/issue-comment-edit-last-with-comments.txtar index 2c43c1c4f95..914e231bc87 100644 --- a/acceptance/testdata/issue/issue-comment-edit-last-with-comments.txtar +++ b/acceptance/testdata/issue/issue-comment-edit-last-with-comments.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript diff --git a/acceptance/testdata/issue/issue-comment-edit-last-without-comments-creates.txtar b/acceptance/testdata/issue/issue-comment-edit-last-without-comments-creates.txtar index bf063a578ef..8d273a00ced 100644 --- a/acceptance/testdata/issue/issue-comment-edit-last-without-comments-creates.txtar +++ b/acceptance/testdata/issue/issue-comment-edit-last-without-comments-creates.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript diff --git a/acceptance/testdata/issue/issue-comment-edit-last-without-comments-errors.txtar b/acceptance/testdata/issue/issue-comment-edit-last-without-comments-errors.txtar index 64e27c599e7..e4c257b011f 100644 --- a/acceptance/testdata/issue/issue-comment-edit-last-without-comments-errors.txtar +++ b/acceptance/testdata/issue/issue-comment-edit-last-without-comments-errors.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript diff --git a/acceptance/testdata/issue/issue-comment-new.txtar b/acceptance/testdata/issue/issue-comment-new.txtar index a8795883963..2605e0d6ea0 100644 --- a/acceptance/testdata/issue/issue-comment-new.txtar +++ b/acceptance/testdata/issue/issue-comment-new.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript diff --git a/acceptance/testdata/issue/issue-create-basic.txtar b/acceptance/testdata/issue/issue-create-basic.txtar index 272e329b57c..e2cb71f5570 100644 --- a/acceptance/testdata/issue/issue-create-basic.txtar +++ b/acceptance/testdata/issue/issue-create-basic.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/issue/issue-create-edit-with-project.txtar b/acceptance/testdata/issue/issue-create-edit-with-project.txtar index f20f02992a4..94055e94a53 100644 --- a/acceptance/testdata/issue/issue-create-edit-with-project.txtar +++ b/acceptance/testdata/issue/issue-create-edit-with-project.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/issue/issue-create-with-metadata.txtar b/acceptance/testdata/issue/issue-create-with-metadata.txtar index b2b8bcb3e31..c9f9f36d5d5 100644 --- a/acceptance/testdata/issue/issue-create-with-metadata.txtar +++ b/acceptance/testdata/issue/issue-create-with-metadata.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/issue/issue-develop-worktree-cross-repo.txtar b/acceptance/testdata/issue/issue-develop-worktree-cross-repo.txtar index 601ad2f0c41..fc5a2e75256 100644 --- a/acceptance/testdata/issue/issue-develop-worktree-cross-repo.txtar +++ b/acceptance/testdata/issue/issue-develop-worktree-cross-repo.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/issue/issue-develop-worktree.txtar b/acceptance/testdata/issue/issue-develop-worktree.txtar index 5e8ebd5fa0a..d92bde5e038 100644 --- a/acceptance/testdata/issue/issue-develop-worktree.txtar +++ b/acceptance/testdata/issue/issue-develop-worktree.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Develop an issue in a fresh worktree, reuse that worktree, and add another # worktree after the local branch exists. diff --git a/acceptance/testdata/issue/issue-list.txtar b/acceptance/testdata/issue/issue-list.txtar index 76a0647ef1c..f3f0017b3ca 100644 --- a/acceptance/testdata/issue/issue-list.txtar +++ b/acceptance/testdata/issue/issue-list.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/issue/issue-view.txtar b/acceptance/testdata/issue/issue-view.txtar index 272e329b57c..e2cb71f5570 100644 --- a/acceptance/testdata/issue/issue-view.txtar +++ b/acceptance/testdata/issue/issue-view.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/issues-2.0/issue-create-and-edit-issue-type.txtar b/acceptance/testdata/issues-2.0/issue-create-and-edit-issue-type.txtar index ae0cb79f5fa..d3088ab9c9e 100644 --- a/acceptance/testdata/issues-2.0/issue-create-and-edit-issue-type.txtar +++ b/acceptance/testdata/issues-2.0/issue-create-and-edit-issue-type.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/issues-2.0/issue-create-and-edit-parent.txtar b/acceptance/testdata/issues-2.0/issue-create-and-edit-parent.txtar index 40a3efbbf55..b1d5efcba38 100644 --- a/acceptance/testdata/issues-2.0/issue-create-and-edit-parent.txtar +++ b/acceptance/testdata/issues-2.0/issue-create-and-edit-parent.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/issues-2.0/issue-create-and-edit-relationships.txtar b/acceptance/testdata/issues-2.0/issue-create-and-edit-relationships.txtar index 6b024ef095e..85bfe2daaa9 100644 --- a/acceptance/testdata/issues-2.0/issue-create-and-edit-relationships.txtar +++ b/acceptance/testdata/issues-2.0/issue-create-and-edit-relationships.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/issues-2.0/issue-edit-sub-issues.txtar b/acceptance/testdata/issues-2.0/issue-edit-sub-issues.txtar index f5e74f1d75c..2022839518d 100644 --- a/acceptance/testdata/issues-2.0/issue-edit-sub-issues.txtar +++ b/acceptance/testdata/issues-2.0/issue-edit-sub-issues.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/issues-2.0/issue-list-filter-by-type.txtar b/acceptance/testdata/issues-2.0/issue-list-filter-by-type.txtar index 35574acb69c..2f13dba3491 100644 --- a/acceptance/testdata/issues-2.0/issue-list-filter-by-type.txtar +++ b/acceptance/testdata/issues-2.0/issue-list-filter-by-type.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/issues-2.0/issue-view-issues-2.0-fields.txtar b/acceptance/testdata/issues-2.0/issue-view-issues-2.0-fields.txtar index 5db98697ef5..28f9adf1195 100644 --- a/acceptance/testdata/issues-2.0/issue-view-issues-2.0-fields.txtar +++ b/acceptance/testdata/issues-2.0/issue-view-issues-2.0-fields.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/label/label.txtar b/acceptance/testdata/label/label.txtar index 27bfe2f751a..2a5b8e2b5b4 100644 --- a/acceptance/testdata/label/label.txtar +++ b/acceptance/testdata/label/label.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup useful env vars diff --git a/acceptance/testdata/org/org-list.txtar b/acceptance/testdata/org/org-list.txtar index fe0b7fae732..4503387ffbf 100644 --- a/acceptance/testdata/org/org-list.txtar +++ b/acceptance/testdata/org/org-list.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true fixture-repo none diff --git a/acceptance/testdata/pr/pr-checkout-by-number.txtar b/acceptance/testdata/pr/pr-checkout-by-number.txtar index cba74cd88c5..842716aa306 100644 --- a/acceptance/testdata/pr/pr-checkout-by-number.txtar +++ b/acceptance/testdata/pr/pr-checkout-by-number.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Set up env vars diff --git a/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar b/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar index c74a041632b..6601ea83601 100644 --- a/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar +++ b/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/pr/pr-checkout-worktree-detach.txtar b/acceptance/testdata/pr/pr-checkout-worktree-detach.txtar index cc10394d08c..e4bf22d11ce 100644 --- a/acceptance/testdata/pr/pr-checkout-worktree-detach.txtar +++ b/acceptance/testdata/pr/pr-checkout-worktree-detach.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Checkout a PR into a worktree with a detached HEAD, then reuse that worktree. diff --git a/acceptance/testdata/pr/pr-checkout-worktree-from-fork.txtar b/acceptance/testdata/pr/pr-checkout-worktree-from-fork.txtar index d8c675b60d7..7bda9fd2110 100644 --- a/acceptance/testdata/pr/pr-checkout-worktree-from-fork.txtar +++ b/acceptance/testdata/pr/pr-checkout-worktree-from-fork.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/pr/pr-checkout-worktree.txtar b/acceptance/testdata/pr/pr-checkout-worktree.txtar index a7f8c817ffb..2a79382be92 100644 --- a/acceptance/testdata/pr/pr-checkout-worktree.txtar +++ b/acceptance/testdata/pr/pr-checkout-worktree.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Checkout a PR into a git worktree, then reuse that worktree, rename its branch, # force sync it, and add a second worktree once the local branch already exists. diff --git a/acceptance/testdata/pr/pr-checkout.txtar b/acceptance/testdata/pr/pr-checkout.txtar index b14a41a086d..f335a1fd86b 100644 --- a/acceptance/testdata/pr/pr-checkout.txtar +++ b/acceptance/testdata/pr/pr-checkout.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/pr/pr-comment-edit-last-with-comments.txtar b/acceptance/testdata/pr/pr-comment-edit-last-with-comments.txtar index 591346e4169..6e2ec703e12 100644 --- a/acceptance/testdata/pr/pr-comment-edit-last-with-comments.txtar +++ b/acceptance/testdata/pr/pr-comment-edit-last-with-comments.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript diff --git a/acceptance/testdata/pr/pr-comment-edit-last-without-comments-creates.txtar b/acceptance/testdata/pr/pr-comment-edit-last-without-comments-creates.txtar index faa6d87bdf1..789b536ff4c 100644 --- a/acceptance/testdata/pr/pr-comment-edit-last-without-comments-creates.txtar +++ b/acceptance/testdata/pr/pr-comment-edit-last-without-comments-creates.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript diff --git a/acceptance/testdata/pr/pr-comment-edit-last-without-comments-errors.txtar b/acceptance/testdata/pr/pr-comment-edit-last-without-comments-errors.txtar index de22fb63b97..c10f00cad8e 100644 --- a/acceptance/testdata/pr/pr-comment-edit-last-without-comments-errors.txtar +++ b/acceptance/testdata/pr/pr-comment-edit-last-without-comments-errors.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript diff --git a/acceptance/testdata/pr/pr-comment-new.txtar b/acceptance/testdata/pr/pr-comment-new.txtar index 9cb9ef29177..906438502e0 100644 --- a/acceptance/testdata/pr/pr-comment-new.txtar +++ b/acceptance/testdata/pr/pr-comment-new.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript diff --git a/acceptance/testdata/pr/pr-create-basic.txtar b/acceptance/testdata/pr/pr-create-basic.txtar index 72618e7d71b..57cecd9c011 100644 --- a/acceptance/testdata/pr/pr-create-basic.txtar +++ b/acceptance/testdata/pr/pr-create-basic.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/pr/pr-create-edit-with-project.txtar b/acceptance/testdata/pr/pr-create-edit-with-project.txtar index 028b1efc1e5..733b0d7faab 100644 --- a/acceptance/testdata/pr/pr-create-edit-with-project.txtar +++ b/acceptance/testdata/pr/pr-create-edit-with-project.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/pr/pr-create-from-issue-develop-base.txtar b/acceptance/testdata/pr/pr-create-from-issue-develop-base.txtar index 3a2826ecb2e..bbd3fc42feb 100644 --- a/acceptance/testdata/pr/pr-create-from-issue-develop-base.txtar +++ b/acceptance/testdata/pr/pr-create-from-issue-develop-base.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Set up env vars diff --git a/acceptance/testdata/pr/pr-create-from-manual-merge-base.txtar b/acceptance/testdata/pr/pr-create-from-manual-merge-base.txtar index 9e99147c5a4..d0c62e5835d 100644 --- a/acceptance/testdata/pr/pr-create-from-manual-merge-base.txtar +++ b/acceptance/testdata/pr/pr-create-from-manual-merge-base.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Set up env vars diff --git a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar index 363fce2f8d2..dca2d903bf4 100644 --- a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar +++ b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true skip 'it creates a fork owned by the user running the test' diff --git a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar index d290a5ab98e..eff2d668bbd 100644 --- a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar +++ b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true skip 'it creates a fork owned by the user running the test' diff --git a/acceptance/testdata/pr/pr-create-no-local-repo.txtar b/acceptance/testdata/pr/pr-create-no-local-repo.txtar index 5caadacc3e5..4fd4e234af5 100644 --- a/acceptance/testdata/pr/pr-create-no-local-repo.txtar +++ b/acceptance/testdata/pr/pr-create-no-local-repo.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar index 1814bbff655..2e039afc7f9 100644 --- a/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar +++ b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true skip 'it creates a fork owned by the user running the test' skip 'this never worked, but could be fixed if we fixed show-refs' diff --git a/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref.txtar b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref.txtar index d2c5f10a464..d1a60b83d5a 100644 --- a/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref.txtar +++ b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true skip 'it creates a fork owned by the user running the test' diff --git a/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar b/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar index d8835b7dd45..c982fbee1d8 100644 --- a/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar +++ b/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true skip 'it creates a fork owned by the user running the test' diff --git a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar index 5ad31a56844..668b0713b01 100644 --- a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar +++ b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true skip 'it creates a fork owned by the user running the test' diff --git a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar index ab8d487fee8..57f789f3ffa 100644 --- a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar +++ b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true skip 'it creates a fork owned by the user running the test' diff --git a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar index 4877e824f57..0b216eeba29 100644 --- a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true skip 'it creates a fork owned by the user running the test' diff --git a/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar index 6fef67600d1..ea6776bf8d7 100644 --- a/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript diff --git a/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar b/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar index 03fbcbfb11c..b227f14fe88 100644 --- a/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar +++ b/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true skip 'it creates a fork owned by the user running the test' diff --git a/acceptance/testdata/pr/pr-create-with-metadata.txtar b/acceptance/testdata/pr/pr-create-with-metadata.txtar index 2ae9daf7c1f..27075aba648 100644 --- a/acceptance/testdata/pr/pr-create-with-metadata.txtar +++ b/acceptance/testdata/pr/pr-create-with-metadata.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/pr/pr-create-without-upstream-config.txtar b/acceptance/testdata/pr/pr-create-without-upstream-config.txtar index 0396c6e96ed..f07b5388b32 100644 --- a/acceptance/testdata/pr/pr-create-without-upstream-config.txtar +++ b/acceptance/testdata/pr/pr-create-without-upstream-config.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # This test is the same as pr-create-basic, except that the git push doesn't include the -u argument # This causes a git config read to fail during gh pr create, but it should not be fatal diff --git a/acceptance/testdata/pr/pr-list.txtar b/acceptance/testdata/pr/pr-list.txtar index 10cc25e81a8..d5fcd05f3ad 100644 --- a/acceptance/testdata/pr/pr-list.txtar +++ b/acceptance/testdata/pr/pr-list.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/pr/pr-merge-merge-strategy.txtar b/acceptance/testdata/pr/pr-merge-merge-strategy.txtar index 6c5506de795..426a8fdd176 100644 --- a/acceptance/testdata/pr/pr-merge-merge-strategy.txtar +++ b/acceptance/testdata/pr/pr-merge-merge-strategy.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/pr/pr-status-respects-cross-org.txtar b/acceptance/testdata/pr/pr-status-respects-cross-org.txtar index 95a07b043aa..31e48b29fb0 100644 --- a/acceptance/testdata/pr/pr-status-respects-cross-org.txtar +++ b/acceptance/testdata/pr/pr-status-respects-cross-org.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true skip 'it creates a fork owned by the user running the test' diff --git a/acceptance/testdata/pr/pr-view-outside-repo.txtar b/acceptance/testdata/pr/pr-view-outside-repo.txtar index d68f443fd92..1f787f6961a 100644 --- a/acceptance/testdata/pr/pr-view-outside-repo.txtar +++ b/acceptance/testdata/pr/pr-view-outside-repo.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/pr/pr-view-same-org-fork.txtar b/acceptance/testdata/pr/pr-view-same-org-fork.txtar index 15cd4949d7c..dd16a45e3d2 100644 --- a/acceptance/testdata/pr/pr-view-same-org-fork.txtar +++ b/acceptance/testdata/pr/pr-view-same-org-fork.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar index 1a1721d931b..49cc062608f 100644 --- a/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar +++ b/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/pr/pr-view-status-respects-push-destination.txtar b/acceptance/testdata/pr/pr-view-status-respects-push-destination.txtar index 7880e637e4b..572de89b340 100644 --- a/acceptance/testdata/pr/pr-view-status-respects-push-destination.txtar +++ b/acceptance/testdata/pr/pr-view-status-respects-push-destination.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript diff --git a/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar index a537c264761..d8f0ca636a2 100644 --- a/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar b/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar index b449d379353..251b11e9ba4 100644 --- a/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript diff --git a/acceptance/testdata/pr/pr-view.txtar b/acceptance/testdata/pr/pr-view.txtar index 1cafe4b19cf..88e37979cfd 100644 --- a/acceptance/testdata/pr/pr-view.txtar +++ b/acceptance/testdata/pr/pr-view.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/project/project-create-delete.txtar b/acceptance/testdata/project/project-create-delete.txtar index a7a8ab114f4..1d5b5b36afd 100644 --- a/acceptance/testdata/project/project-create-delete.txtar +++ b/acceptance/testdata/project/project-create-delete.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/release/release-create.txtar b/acceptance/testdata/release/release-create.txtar index e5c3b610b54..27a6e2a649b 100644 --- a/acceptance/testdata/release/release-create.txtar +++ b/acceptance/testdata/release/release-create.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/release/release-delete.txtar b/acceptance/testdata/release/release-delete.txtar index 02147863f81..d48fb4ffb4d 100644 --- a/acceptance/testdata/release/release-delete.txtar +++ b/acceptance/testdata/release/release-delete.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/release/release-list.txtar b/acceptance/testdata/release/release-list.txtar index e12b76f97ba..e4957438f29 100644 --- a/acceptance/testdata/release/release-list.txtar +++ b/acceptance/testdata/release/release-list.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/release/release-upload-download.txtar b/acceptance/testdata/release/release-upload-download.txtar index 6543fb9d8f0..450fd0de146 100644 --- a/acceptance/testdata/release/release-upload-download.txtar +++ b/acceptance/testdata/release/release-upload-download.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/release/release-view.txtar b/acceptance/testdata/release/release-view.txtar index 7bc6049b120..a3ff187e750 100644 --- a/acceptance/testdata/release/release-view.txtar +++ b/acceptance/testdata/release/release-view.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/repo/repo-archive-unarchive.txtar b/acceptance/testdata/repo/repo-archive-unarchive.txtar index 7c2f34e1e67..2f190d87019 100644 --- a/acceptance/testdata/repo/repo-archive-unarchive.txtar +++ b/acceptance/testdata/repo/repo-archive-unarchive.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo isolated REPO diff --git a/acceptance/testdata/repo/repo-autolink.txtar b/acceptance/testdata/repo/repo-autolink.txtar index 59d01b8df46..f3962ae60d9 100644 --- a/acceptance/testdata/repo/repo-autolink.txtar +++ b/acceptance/testdata/repo/repo-autolink.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository to hold the autolink references fixture-repo shared REPO diff --git a/acceptance/testdata/repo/repo-clone.txtar b/acceptance/testdata/repo/repo-clone.txtar index ddd08fad757..b5b773eeacc 100644 --- a/acceptance/testdata/repo/repo-clone.txtar +++ b/acceptance/testdata/repo/repo-clone.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/repo/repo-create-bare.txtar b/acceptance/testdata/repo/repo-create-bare.txtar index d71356f4756..f9ea03b92ea 100644 --- a/acceptance/testdata/repo/repo-create-bare.txtar +++ b/acceptance/testdata/repo/repo-create-bare.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # It's unclear what we want to do with these acceptance tests beyond our GHEC discovery, so skip new ones by default skip diff --git a/acceptance/testdata/repo/repo-create-view.txtar b/acceptance/testdata/repo/repo-create-view.txtar index a543f8d33bf..8085528dffa 100644 --- a/acceptance/testdata/repo/repo-create-view.txtar +++ b/acceptance/testdata/repo/repo-create-view.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/repo/repo-delete.txtar b/acceptance/testdata/repo/repo-delete.txtar index a629995b536..9df4b14c321 100644 --- a/acceptance/testdata/repo/repo-delete.txtar +++ b/acceptance/testdata/repo/repo-delete.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/repo/repo-deploy-key.txtar b/acceptance/testdata/repo/repo-deploy-key.txtar index 5cc228acd2b..5737b5270ae 100644 --- a/acceptance/testdata/repo/repo-deploy-key.txtar +++ b/acceptance/testdata/repo/repo-deploy-key.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create and clone a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/repo/repo-edit.txtar b/acceptance/testdata/repo/repo-edit.txtar index ca11a02be1e..c559ebe85a9 100644 --- a/acceptance/testdata/repo/repo-edit.txtar +++ b/acceptance/testdata/repo/repo-edit.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo isolated REPO diff --git a/acceptance/testdata/repo/repo-fork-sync.txtar b/acceptance/testdata/repo/repo-fork-sync.txtar index 9a801eb5724..a5ff6de7683 100644 --- a/acceptance/testdata/repo/repo-fork-sync.txtar +++ b/acceptance/testdata/repo/repo-fork-sync.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/repo/repo-list-rename.txtar b/acceptance/testdata/repo/repo-list-rename.txtar index c483adaab72..cc99e3b1fa4 100644 --- a/acceptance/testdata/repo/repo-list-rename.txtar +++ b/acceptance/testdata/repo/repo-list-rename.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/repo/repo-read-dir.txtar b/acceptance/testdata/repo/repo-read-dir.txtar index a997dcf71bc..1f6a7930020 100644 --- a/acceptance/testdata/repo/repo-read-dir.txtar +++ b/acceptance/testdata/repo/repo-read-dir.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # List directory contents of a repository without cloning. diff --git a/acceptance/testdata/repo/repo-read-file.txtar b/acceptance/testdata/repo/repo-read-file.txtar index 2f9786c618b..e30c4791506 100644 --- a/acceptance/testdata/repo/repo-read-file.txtar +++ b/acceptance/testdata/repo/repo-read-file.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Read files from a repository without cloning, in several modes. diff --git a/acceptance/testdata/repo/repo-rename-transfer-ownership.txtar b/acceptance/testdata/repo/repo-rename-transfer-ownership.txtar index e93bfbebd44..3761d832f02 100644 --- a/acceptance/testdata/repo/repo-rename-transfer-ownership.txtar +++ b/acceptance/testdata/repo/repo-rename-transfer-ownership.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/repo/repo-set-default.txtar b/acceptance/testdata/repo/repo-set-default.txtar index 886aa6f7896..8b9dd56d095 100644 --- a/acceptance/testdata/repo/repo-set-default.txtar +++ b/acceptance/testdata/repo/repo-set-default.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create and clone a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/repo/repo-sync-worktree.txtar b/acceptance/testdata/repo/repo-sync-worktree.txtar index 12227f008a1..d69541b2797 100644 --- a/acceptance/testdata/repo/repo-sync-worktree.txtar +++ b/acceptance/testdata/repo/repo-sync-worktree.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/repo/repo-sync.txtar b/acceptance/testdata/repo/repo-sync.txtar index e096c8993a6..020d8c430e0 100644 --- a/acceptance/testdata/repo/repo-sync.txtar +++ b/acceptance/testdata/repo/repo-sync.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/ruleset/ruleset.txtar b/acceptance/testdata/ruleset/ruleset.txtar index b810bc8fc4f..2eb0acd76a2 100644 --- a/acceptance/testdata/ruleset/ruleset.txtar +++ b/acceptance/testdata/ruleset/ruleset.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript diff --git a/acceptance/testdata/search/search-issues.txtar b/acceptance/testdata/search/search-issues.txtar index 9707ef9b793..2d1228020d2 100644 --- a/acceptance/testdata/search/search-issues.txtar +++ b/acceptance/testdata/search/search-issues.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Create a repository with a file so it has a default branch fixture-repo shared REPO diff --git a/acceptance/testdata/secret/secret-org-with-selected-visibility.txtar b/acceptance/testdata/secret/secret-org-with-selected-visibility.txtar index dd0e383b681..6211b657c08 100644 --- a/acceptance/testdata/secret/secret-org-with-selected-visibility.txtar +++ b/acceptance/testdata/secret/secret-org-with-selected-visibility.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript env2upper SECRET_NAME=${SCRIPT_NAME}_${RANDOM_STRING} diff --git a/acceptance/testdata/secret/secret-org.txtar b/acceptance/testdata/secret/secret-org.txtar index 4314dc81e6f..137eda877e1 100644 --- a/acceptance/testdata/secret/secret-org.txtar +++ b/acceptance/testdata/secret/secret-org.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript env REPO=${SCRIPT_NAME}-${RANDOM_STRING} diff --git a/acceptance/testdata/secret/secret-repo-env.txtar b/acceptance/testdata/secret/secret-repo-env.txtar index 3f1b3e45971..93d123f5c8c 100644 --- a/acceptance/testdata/secret/secret-repo-env.txtar +++ b/acceptance/testdata/secret/secret-repo-env.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript env ENV_NAME=testscripts-${RANDOM_STRING} diff --git a/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar index d3e92e26b59..080f1d7d7e5 100644 --- a/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar +++ b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-install-force.txtar b/acceptance/testdata/skills/skills-install-force.txtar index d822fd8867b..1d9b19f906b 100644 --- a/acceptance/testdata/skills/skills-install-force.txtar +++ b/acceptance/testdata/skills/skills-install-force.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-install-from-local.txtar b/acceptance/testdata/skills/skills-install-from-local.txtar index 66fb11681cd..e4032858452 100644 --- a/acceptance/testdata/skills/skills-install-from-local.txtar +++ b/acceptance/testdata/skills/skills-install-from-local.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-install-invalid-agent.txtar b/acceptance/testdata/skills/skills-install-invalid-agent.txtar index c680c76de76..c67b6cda539 100644 --- a/acceptance/testdata/skills/skills-install-invalid-agent.txtar +++ b/acceptance/testdata/skills/skills-install-invalid-agent.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-install-invalid-repo.txtar b/acceptance/testdata/skills/skills-install-invalid-repo.txtar index 367b7965add..0b642802c4a 100644 --- a/acceptance/testdata/skills/skills-install-invalid-repo.txtar +++ b/acceptance/testdata/skills/skills-install-invalid-repo.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-install-namespaced.txtar b/acceptance/testdata/skills/skills-install-namespaced.txtar index 5ff22ad2bb6..558626670ff 100644 --- a/acceptance/testdata/skills/skills-install-namespaced.txtar +++ b/acceptance/testdata/skills/skills-install-namespaced.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-install-nested-files.txtar b/acceptance/testdata/skills/skills-install-nested-files.txtar index 2877ac55d26..18708cbaf8c 100644 --- a/acceptance/testdata/skills/skills-install-nested-files.txtar +++ b/acceptance/testdata/skills/skills-install-nested-files.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar index 93671a8a0ac..bf8ccc581eb 100644 --- a/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar +++ b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-install-pin.txtar b/acceptance/testdata/skills/skills-install-pin.txtar index 84978ca61e4..d3a8b7155ee 100644 --- a/acceptance/testdata/skills/skills-install-pin.txtar +++ b/acceptance/testdata/skills/skills-install-pin.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-install-scope.txtar b/acceptance/testdata/skills/skills-install-scope.txtar index 593c052ef68..0b124137107 100644 --- a/acceptance/testdata/skills/skills-install-scope.txtar +++ b/acceptance/testdata/skills/skills-install-scope.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-install.txtar b/acceptance/testdata/skills/skills-install.txtar index bda81a85b57..16c2dca2aad 100644 --- a/acceptance/testdata/skills/skills-install.txtar +++ b/acceptance/testdata/skills/skills-install.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-preview-noninteractive.txtar b/acceptance/testdata/skills/skills-preview-noninteractive.txtar index f01327a4856..28da7364975 100644 --- a/acceptance/testdata/skills/skills-preview-noninteractive.txtar +++ b/acceptance/testdata/skills/skills-preview-noninteractive.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-preview.txtar b/acceptance/testdata/skills/skills-preview.txtar index b2050af435a..a25d17fc384 100644 --- a/acceptance/testdata/skills/skills-preview.txtar +++ b/acceptance/testdata/skills/skills-preview.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-publish-dir-remote.txtar b/acceptance/testdata/skills/skills-publish-dir-remote.txtar index 5725841fbfd..233bc39154e 100644 --- a/acceptance/testdata/skills/skills-publish-dir-remote.txtar +++ b/acceptance/testdata/skills/skills-publish-dir-remote.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-publish-dry-run.txtar b/acceptance/testdata/skills/skills-publish-dry-run.txtar index 0ab7be60ba1..050fb65bd15 100644 --- a/acceptance/testdata/skills/skills-publish-dry-run.txtar +++ b/acceptance/testdata/skills/skills-publish-dry-run.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-publish-lifecycle.txtar b/acceptance/testdata/skills/skills-publish-lifecycle.txtar index ed39c5bd211..7d772420a3e 100644 --- a/acceptance/testdata/skills/skills-publish-lifecycle.txtar +++ b/acceptance/testdata/skills/skills-publish-lifecycle.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Full publish lifecycle: create repo, publish, install from it, clean up diff --git a/acceptance/testdata/skills/skills-search.txtar b/acceptance/testdata/skills/skills-search.txtar index 7dc24db2af2..640b9433f95 100644 --- a/acceptance/testdata/skills/skills-search.txtar +++ b/acceptance/testdata/skills/skills-search.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-update-inplace.txtar b/acceptance/testdata/skills/skills-update-inplace.txtar index 4fecb809cff..ef473f3f17b 100644 --- a/acceptance/testdata/skills/skills-update-inplace.txtar +++ b/acceptance/testdata/skills/skills-update-inplace.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-update-noinstalled.txtar b/acceptance/testdata/skills/skills-update-noinstalled.txtar index 0fe60302b31..f98406c4430 100644 --- a/acceptance/testdata/skills/skills-update-noinstalled.txtar +++ b/acceptance/testdata/skills/skills-update-noinstalled.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/skills/skills-update.txtar b/acceptance/testdata/skills/skills-update.txtar index 5f5194286be..03607b8a09a 100644 --- a/acceptance/testdata/skills/skills-update.txtar +++ b/acceptance/testdata/skills/skills-update.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/ssh-key/ssh-key.txtar b/acceptance/testdata/ssh-key/ssh-key.txtar index f7fd2c63978..22044b43e0c 100644 --- a/acceptance/testdata/ssh-key/ssh-key.txtar +++ b/acceptance/testdata/ssh-key/ssh-key.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: true skip 'it modifies the user''s personal GitHub account SSH keys' diff --git a/acceptance/testdata/telemetry/accessibility-dimensions-disabled.txtar b/acceptance/testdata/telemetry/accessibility-dimensions-disabled.txtar index e3617f90c75..4fcdcf4626a 100644 --- a/acceptance/testdata/telemetry/accessibility-dimensions-disabled.txtar +++ b/acceptance/testdata/telemetry/accessibility-dimensions-disabled.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/telemetry/accessibility-dimensions.txtar b/acceptance/testdata/telemetry/accessibility-dimensions.txtar index 3988f402285..26741902ede 100644 --- a/acceptance/testdata/telemetry/accessibility-dimensions.txtar +++ b/acceptance/testdata/telemetry/accessibility-dimensions.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/telemetry/agent-dimensions.txtar b/acceptance/testdata/telemetry/agent-dimensions.txtar index bb6a208597d..7e56b5da53e 100644 --- a/acceptance/testdata/telemetry/agent-dimensions.txtar +++ b/acceptance/testdata/telemetry/agent-dimensions.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/telemetry/command-invocation.txtar b/acceptance/testdata/telemetry/command-invocation.txtar index 18abc1ac824..77ebb235449 100644 --- a/acceptance/testdata/telemetry/command-invocation.txtar +++ b/acceptance/testdata/telemetry/command-invocation.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar b/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar index 9087881a55e..ab716b14d77 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar index 24966fa3a5b..5cae921a090 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar index f0f668421d1..a21c661da13 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Third-party extensions must not generate telemetry events, since the # extension command name can be a user-authored identifier (e.g. an diff --git a/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar index 1c145ba4480..da8595ab0f7 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar index f11191e98af..309f190e827 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none 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 16f96524077..8ba4a67aec4 100644 --- a/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar +++ b/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar index d6ddc6109af..07ae59787e1 100644 --- a/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar +++ b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/variable/variable-org.txtar b/acceptance/testdata/variable/variable-org.txtar index 7f7f6197180..5b16a35d62c 100644 --- a/acceptance/testdata/variable/variable-org.txtar +++ b/acceptance/testdata/variable/variable-org.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false fixture-repo none diff --git a/acceptance/testdata/variable/variable-repo-env.txtar b/acceptance/testdata/variable/variable-repo-env.txtar index da2445ea100..765e6a0c2cf 100644 --- a/acceptance/testdata/variable/variable-repo-env.txtar +++ b/acceptance/testdata/variable/variable-repo-env.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript env ENV_NAME=testscripts-${RANDOM_STRING} diff --git a/acceptance/testdata/variable/variable-repo.txtar b/acceptance/testdata/variable/variable-repo.txtar index cf20c9a33f4..0fc804a9c68 100644 --- a/acceptance/testdata/variable/variable-repo.txtar +++ b/acceptance/testdata/variable/variable-repo.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Setup environment variables used for testscript env2upper VAR_NAME=TESTSCRIPTS_${RANDOM_STRING} diff --git a/acceptance/testdata/workflow/cache-list-delete.txtar b/acceptance/testdata/workflow/cache-list-delete.txtar index ba10629338a..263a0fd0ba2 100644 --- a/acceptance/testdata/workflow/cache-list-delete.txtar +++ b/acceptance/testdata/workflow/cache-list-delete.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/workflow/cache-list-empty.txtar b/acceptance/testdata/workflow/cache-list-empty.txtar index 694151da8b3..bb4f4af2481 100644 --- a/acceptance/testdata/workflow/cache-list-empty.txtar +++ b/acceptance/testdata/workflow/cache-list-empty.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # It's unclear what we want to do with these acceptance tests beyond our GHEC discovery, so skip new ones by default skip diff --git a/acceptance/testdata/workflow/run-cancel.txtar b/acceptance/testdata/workflow/run-cancel.txtar index 9830bf06a24..8bbffbd0893 100644 --- a/acceptance/testdata/workflow/run-cancel.txtar +++ b/acceptance/testdata/workflow/run-cancel.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/workflow/run-delete.txtar b/acceptance/testdata/workflow/run-delete.txtar index 6b87f203216..c98a6a6e2f1 100644 --- a/acceptance/testdata/workflow/run-delete.txtar +++ b/acceptance/testdata/workflow/run-delete.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/workflow/run-download.txtar b/acceptance/testdata/workflow/run-download.txtar index 35926cba020..6afb2b0c3ef 100644 --- a/acceptance/testdata/workflow/run-download.txtar +++ b/acceptance/testdata/workflow/run-download.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Set up env diff --git a/acceptance/testdata/workflow/run-rerun.txtar b/acceptance/testdata/workflow/run-rerun.txtar index 8413aff868d..2eaadc3733e 100644 --- a/acceptance/testdata/workflow/run-rerun.txtar +++ b/acceptance/testdata/workflow/run-rerun.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar b/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar index f2d407a9974..b11cf82592e 100644 --- a/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar +++ b/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # This test ensures that a malicious workflow which emit terminal control sequences (ESC, OSC, CSI) in # its log output does not result in terminal injection when logs are displayed using `gh run view --log` diff --git a/acceptance/testdata/workflow/run-view.txtar b/acceptance/testdata/workflow/run-view.txtar index 0002afa2b60..bb82380c262 100644 --- a/acceptance/testdata/workflow/run-view.txtar +++ b/acceptance/testdata/workflow/run-view.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/workflow/workflow-enable-disable.txtar b/acceptance/testdata/workflow/workflow-enable-disable.txtar index 8965561a64f..9804ade40a4 100644 --- a/acceptance/testdata/workflow/workflow-enable-disable.txtar +++ b/acceptance/testdata/workflow/workflow-enable-disable.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/workflow/workflow-list.txtar b/acceptance/testdata/workflow/workflow-list.txtar index 70722e2e0db..17171fe65e8 100644 --- a/acceptance/testdata/workflow/workflow-list.txtar +++ b/acceptance/testdata/workflow/workflow-list.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/workflow/workflow-run.txtar b/acceptance/testdata/workflow/workflow-run.txtar index 1cbdad36728..e24171f0876 100644 --- a/acceptance/testdata/workflow/workflow-run.txtar +++ b/acceptance/testdata/workflow/workflow-run.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/testdata/workflow/workflow-view.txtar b/acceptance/testdata/workflow/workflow-view.txtar index b43c04c03d1..4fde30b9108 100644 --- a/acceptance/testdata/workflow/workflow-view.txtar +++ b/acceptance/testdata/workflow/workflow-view.txtar @@ -1,3 +1,4 @@ +# requires-user-capability: false # Use gh as a credential helper exec gh auth setup-git diff --git a/acceptance/user_capability_test.go b/acceptance/user_capability_test.go index dc78b9abc77..1472a3b7762 100644 --- a/acceptance/user_capability_test.go +++ b/acceptance/user_capability_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -25,6 +26,61 @@ when the script needs clean or repository-global state, and none when the script creates its own repositories or does not need one. See acceptance/README.md#script-metadata for details.` +func tokenHasUserCapability(token string) (bool, error) { + switch { + case strings.HasPrefix(token, "ghp_"), + strings.HasPrefix(token, "gho_"), + strings.HasPrefix(token, "github_pat_"), + strings.HasPrefix(token, "ghu_"): + return true, nil + case strings.HasPrefix(token, "ghs_"): + return false, nil + default: + return false, fmt.Errorf("GH_ACCEPTANCE_TOKEN has an unsupported token prefix") + } +} + +func requiresUserCapabilityForScript(file string) (bool, error) { + f, err := os.Open(file) + if err != nil { + return false, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return false, err + } + return false, fmt.Errorf("%s: first line must be '# requires-user-capability: true' or '# requires-user-capability: false'", file) + } + + var requiresUserCapability bool + switch strings.TrimSuffix(scanner.Text(), "\r") { + case "# requires-user-capability: true": + requiresUserCapability = true + case "# requires-user-capability: false": + requiresUserCapability = false + default: + return false, fmt.Errorf("%s: first line must be '# requires-user-capability: true' or '# requires-user-capability: false'", file) + } + + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + if strings.HasPrefix(line, "-- ") && strings.HasSuffix(line, " --") { + break + } + if strings.HasPrefix(line, "# requires-user-capability:") { + return false, fmt.Errorf("%s: script must contain exactly one requires-user-capability declaration", file) + } + } + if err := scanner.Err(); err != nil { + return false, err + } + + return requiresUserCapability, nil +} + func validateFixtureRepositoryDeclaration(file string) error { f, err := os.Open(file) if err != nil { @@ -72,6 +128,43 @@ func validateFixtureRepositoryDeclaration(file string) error { } } +func TestTokenHasUserCapability(t *testing.T) { + tests := []struct { + token string + want bool + }{ + {token: "ghp_token", want: true}, + {token: "gho_token", want: true}, + {token: "github_pat_token", want: true}, + {token: "ghu_token", want: true}, + {token: "ghs_token", want: false}, + } + + for _, tt := range tests { + t.Run(tt.token[:4], func(t *testing.T) { + got, err := tokenHasUserCapability(tt.token) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } + + _, err := tokenHasUserCapability("unsupported") + assert.EqualError(t, err, "GH_ACCEPTANCE_TOKEN has an unsupported token prefix") +} + +func TestAcceptanceScriptsDeclareUserCapabilityRequirement(t *testing.T) { + files, err := filepath.Glob(filepath.Join("testdata", "*", "*.txtar")) + require.NoError(t, err) + require.NotEmpty(t, files) + + for _, file := range files { + t.Run(file, func(t *testing.T) { + _, err := requiresUserCapabilityForScript(file) + require.NoError(t, err) + }) + } +} + func TestAcceptanceScriptsDeclareFixtureRepository(t *testing.T) { files, err := filepath.Glob(filepath.Join("testdata", "*", "*.txtar")) require.NoError(t, err) @@ -84,6 +177,55 @@ func TestAcceptanceScriptsDeclareFixtureRepository(t *testing.T) { } } +func TestRequiresUserCapabilityForScriptErrors(t *testing.T) { + tests := []struct { + name string + content string + wantErr string + }{ + { + name: "missing", + content: "", + wantErr: "first line must be", + }, + { + name: "not first", + content: "# explanation\n# requires-user-capability: true\n", + wantErr: "first line must be", + }, + { + name: "invalid value", + content: "# requires-user-capability: unknown\n", + wantErr: "first line must be", + }, + { + name: "multiple", + content: "# requires-user-capability: false\n# requires-user-capability: true\n", + wantErr: "script must contain exactly one requires-user-capability declaration", + }, + { + name: "ignores archive contents", + content: "# requires-user-capability: false\n\n" + + "-- script.sh --\n" + + "# requires-user-capability: true\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file := filepath.Join(t.TempDir(), "script.txtar") + require.NoError(t, os.WriteFile(file, []byte(tt.content), 0o600)) + + _, err := requiresUserCapabilityForScript(file) + if tt.wantErr == "" { + require.NoError(t, err) + } else { + require.ErrorContains(t, err, tt.wantErr) + } + }) + } +} + func TestValidateFixtureRepositoryDeclaration(t *testing.T) { tests := []struct { name string diff --git a/go.mod b/go.mod index 8bb5858aa1e..dfdfe88a52b 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/cli/go-gh/v2 v2.16.0 - github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 + github.com/cli/go-internal v0.0.0-20260902074248-d0d5505d94b4 github.com/cli/oauth v1.2.2 github.com/cli/safeexec v1.0.1 github.com/cpuguy83/go-md2man/v2 v2.0.7 diff --git a/go.sum b/go.sum index 0d36a8f0947..5d2f0dc5fce 100644 --- a/go.sum +++ b/go.sum @@ -147,8 +147,8 @@ 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.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/go-internal v0.0.0-20260902074248-d0d5505d94b4 h1:LS5IvRguPm+poTRAmG8tT6grnRJXNL3nsi5UpGwdznU= +github.com/cli/go-internal v0.0.0-20260902074248-d0d5505d94b4/go.mod h1:FMBrr+8/NHjAAk8WRyKpaenrDfN6D6mFq7cWo+bi7sA= github.com/cli/oauth v1.2.2 h1:/qG/wok8jzu66tx7q+duGOIp4DT5P/ACXrdc33UoNUQ= github.com/cli/oauth v1.2.2/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4= github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= From 7901e7efa2477b7adef3d562758dd597cd6698f2 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 9 Sep 2026 17:48:17 +0200 Subject: [PATCH 18/22] Address acceptance capability review Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d59cb8ae-bb43-4e07-8950-2e75cd3c3e75 --- .../skills/writing-acceptance-tests/SKILL.md | 2 +- acceptance/README.md | 2 +- acceptance/user_capability_test.go | 20 +++++++++++++++++-- go.mod | 2 +- go.sum | 4 ++-- 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.github/skills/writing-acceptance-tests/SKILL.md b/.github/skills/writing-acceptance-tests/SKILL.md index e93f335c09c..2f57ce93bd4 100644 --- a/.github/skills/writing-acceptance-tests/SKILL.md +++ b/.github/skills/writing-acceptance-tests/SKILL.md @@ -133,7 +133,7 @@ Run metadata checks without live credentials: ```sh go test -tags=acceptance \ - -run '^(TestSelectAcceptanceTestGroups|TestFilterAcceptanceScripts|TestTokenHasUserCapability|TestAcceptanceScriptsDeclareUserCapabilityRequirement|TestAcceptanceScriptsDeclareFixtureRepository|TestRequiresUserCapabilityForScriptErrors|TestValidateFixtureRepositoryDeclaration|TestFixtureRepositoryManager)$' \ + -run '^(TestSelectAcceptanceTestGroups|TestFilterAcceptanceScripts|TestTokenHasUserCapability|TestAcceptanceScriptsDeclareUserCapabilityRequirement|TestAcceptanceScriptsDeclareFixtureRepository|TestRequiresUserCapabilityForScript|TestValidateFixtureRepositoryDeclaration|TestFixtureRepositoryManager)$' \ ./acceptance ``` diff --git a/acceptance/README.md b/acceptance/README.md index f593b856441..d5062527d49 100644 --- a/acceptance/README.md +++ b/acceptance/README.md @@ -24,7 +24,7 @@ The token to use for authenticating with the `GH_ACCEPTANCE_HOST`. This must alr It's recommended to create and use a Legacy PAT for this; Fine-Grained PATs do not offer all the necessary privileges required. You can use an OAuth token provided via `gh auth login --web` and can provide it to the acceptance tests via `GH_ACCEPTANCE_TOKEN=$(gh auth token --hostname )` but this can be a bit confusing and annoying if you `gh auth login` again without `-s` and lose the required scopes. -The test harness infers whether a token authenticates a user from GitHub's documented token prefixes. OAuth (`gho_`), classic PAT (`ghp_`), fine-grained PAT (`github_pat_`), and GitHub App user (`ghu_`) tokens provide user capabilities. GitHub App installation (`ghs_`) tokens do not, so scripts marked `requires-user-capability: true` are skipped. +The test harness infers whether a token authenticates a user from GitHub's documented token prefixes. OAuth (`gho_`), classic PAT (`ghp_`), fine-grained PAT (`github_pat_`), and GitHub App user (`ghu_`) tokens provide user capabilities. GitHub App installation (`ghs_`) tokens do not, so scripts marked `requires-user-capability: true` are omitted from unfiltered runs. Explicitly selecting an incompatible script with `GH_ACCEPTANCE_SCRIPT` fails with an error instead. Managed fixture repositories reduce repository creation by sharing state where tests can safely coexist. diff --git a/acceptance/user_capability_test.go b/acceptance/user_capability_test.go index 1472a3b7762..92484d0c8b7 100644 --- a/acceptance/user_capability_test.go +++ b/acceptance/user_capability_test.go @@ -177,12 +177,27 @@ func TestAcceptanceScriptsDeclareFixtureRepository(t *testing.T) { } } -func TestRequiresUserCapabilityForScriptErrors(t *testing.T) { +func TestRequiresUserCapabilityForScript(t *testing.T) { tests := []struct { name string content string + want bool wantErr string }{ + { + name: "requires user capability", + content: "# requires-user-capability: true\n", + want: true, + }, + { + name: "does not require user capability", + content: "# requires-user-capability: false\n", + }, + { + name: "supports CRLF", + content: "# requires-user-capability: true\r\n", + want: true, + }, { name: "missing", content: "", @@ -216,9 +231,10 @@ func TestRequiresUserCapabilityForScriptErrors(t *testing.T) { file := filepath.Join(t.TempDir(), "script.txtar") require.NoError(t, os.WriteFile(file, []byte(tt.content), 0o600)) - _, err := requiresUserCapabilityForScript(file) + got, err := requiresUserCapabilityForScript(file) if tt.wantErr == "" { require.NoError(t, err) + assert.Equal(t, tt.want, got) } else { require.ErrorContains(t, err, tt.wantErr) } diff --git a/go.mod b/go.mod index dfdfe88a52b..8bb5858aa1e 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/cli/go-gh/v2 v2.16.0 - github.com/cli/go-internal v0.0.0-20260902074248-d0d5505d94b4 + github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 github.com/cli/oauth v1.2.2 github.com/cli/safeexec v1.0.1 github.com/cpuguy83/go-md2man/v2 v2.0.7 diff --git a/go.sum b/go.sum index 5d2f0dc5fce..0d36a8f0947 100644 --- a/go.sum +++ b/go.sum @@ -147,8 +147,8 @@ 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.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-20260902074248-d0d5505d94b4 h1:LS5IvRguPm+poTRAmG8tT6grnRJXNL3nsi5UpGwdznU= -github.com/cli/go-internal v0.0.0-20260902074248-d0d5505d94b4/go.mod h1:FMBrr+8/NHjAAk8WRyKpaenrDfN6D6mFq7cWo+bi7sA= +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= github.com/cli/oauth v1.2.2/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4= github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= From 9174ffb070f71162601f4a07ac643c73d00f133d Mon Sep 17 00:00:00 2001 From: Sergio Padrino Date: Wed, 9 Sep 2026 18:35:18 +0200 Subject: [PATCH 19/22] Validate repository names during interactive creation (#14313) Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/repo/create/create.go | 3 ++ pkg/cmd/repo/create/create_test.go | 50 ++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index f10d6a6ff8d..e8bc2660648 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -931,6 +931,9 @@ func interactiveRepoNameAndOwner(client *http.Client, hostname string, prompter if err != nil { return "", "", err } + if strings.TrimSpace(name) == "" { + return "", "", errors.New("repository name cannot be blank") + } name, owner, err := splitNameAndOwner(name) if err != nil { diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 6c28858ee12..eac15bf45ce 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -198,6 +198,52 @@ func Test_createRun(t *testing.T) { wantErr bool errMsg string }{ + { + name: "interactive create from scratch with empty name", + opts: &CreateOptions{Interactive: true}, + tty: true, + promptStubs: func(p *prompter.PrompterMock) { + p.InputFunc = func(message, defaultValue string) (string, error) { + if message != "Repository name" || len(p.InputCalls()) != 1 { + return "", fmt.Errorf("unexpected input prompt: %s", message) + } + return "", nil + } + p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { + switch message { + case "What would you like to do?": + return prompter.IndexFor(options, "Create a new repository on github.com from scratch") + default: + return 0, fmt.Errorf("unexpected select prompt: %s", message) + } + } + }, + wantErr: true, + errMsg: "repository name cannot be blank", + }, + { + name: "interactive create from scratch with whitespace-only name", + opts: &CreateOptions{Interactive: true}, + tty: true, + promptStubs: func(p *prompter.PrompterMock) { + p.InputFunc = func(message, defaultValue string) (string, error) { + if message != "Repository name" || len(p.InputCalls()) != 1 { + return "", fmt.Errorf("unexpected input prompt: %s", message) + } + return " \t ", nil + } + p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { + switch message { + case "What would you like to do?": + return prompter.IndexFor(options, "Create a new repository on github.com from scratch") + default: + return 0, fmt.Errorf("unexpected select prompt: %s", message) + } + } + }, + wantErr: true, + errMsg: "repository name cannot be blank", + }, { name: "interactive create from scratch with gitignore and license", opts: &CreateOptions{Interactive: true}, @@ -1077,9 +1123,9 @@ func Test_createRun(t *testing.T) { if tt.wantErr { require.Error(t, err) assert.Contains(t, err.Error(), tt.errMsg) - return + } else { + require.NoError(t, err) } - require.NoError(t, err) assert.Equal(t, tt.wantStdout, stdout.String()) assert.Equal(t, "", stderr.String()) }) From 30419651a944b4351d7cca65270d0790685d35c5 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 28 Aug 2026 10:56:21 +0200 Subject: [PATCH 20/22] Propagate gh path to extensions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d2a360b-c34f-4ba5-8bf9-5105d9347fb5 --- .../testdata/extension/extension-env.txtar | 16 +++++++++++++++- pkg/cmd/extension/manager.go | 9 +++++---- pkg/cmd/factory/default.go | 2 +- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/acceptance/testdata/extension/extension-env.txtar b/acceptance/testdata/extension/extension-env.txtar index f0b759ddead..f0368afe160 100644 --- a/acceptance/testdata/extension/extension-env.txtar +++ b/acceptance/testdata/extension/extension-env.txtar @@ -11,7 +11,8 @@ fixture-repo none env EXT_NAME=printenv-${RANDOM_STRING} env EXT_DIR=gh-${EXT_NAME} -# Setup a local extension that reports the value of GH_EXTENSION +# Setup a local extension that reports the extension environment and verifies +# it can invoke the current gh executable without relying on PATH mkdir $EXT_DIR mv print-env.sh $EXT_DIR/$EXT_DIR chmod 777 $EXT_DIR/$EXT_DIR @@ -24,10 +25,12 @@ defer gh extension remove $EXT_NAME # Verify GH_EXTENSION is set when the extension is run as gh exec gh $EXT_NAME stdout 'GH_EXTENSION=1' +stdout 'GH_PATH was set correctly' # Verify GH_EXTENSION is set when the extension is run via gh extension exec exec gh extension exec $EXT_NAME stdout 'GH_EXTENSION=1' +stdout 'GH_PATH was set correctly' # Verify GH_EXTENSION is absent when the extension is run standalone exec ./$EXT_DIR @@ -39,4 +42,15 @@ stdout 'GH_EXTENSION`: set to `1` by gh when it invokes an extension' -- print-env.sh -- #!/usr/bin/env bash +set -e + echo "GH_EXTENSION=${GH_EXTENSION:-0}" + +if [[ "${GH_EXTENSION:-0}" == "1" ]]; then + expected_token=$GH_TOKEN + mkdir -p empty-path + PATH=$PWD/empty-path + actual_token=$("$GH_PATH" auth token --hostname "$GH_HOST") + [[ "$actual_token" == "$expected_token" ]] + echo "GH_PATH was set correctly" +fi diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index 112efad876b..ca802bf2a71 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -53,10 +53,11 @@ type Manager struct { gitClient gitClient config gh.Config io *iostreams.IOStreams + ghPath string dryRunMode bool } -func NewManager(ios *iostreams.IOStreams, gc *git.Client) *Manager { +func NewManager(ios *iostreams.IOStreams, gc *git.Client, ghPath string) *Manager { return &Manager{ dataDir: config.DataDir, updateDir: func() string { @@ -74,6 +75,7 @@ func NewManager(ios *iostreams.IOStreams, gc *git.Client) *Manager { }, io: ios, gitClient: &gitExecuter{client: gc}, + ghPath: ghPath, } } @@ -128,9 +130,8 @@ func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Wri forwardArgs = append([]string{"-c", `command "$@"`, "--", exe}, forwardArgs...) externalCmd = m.newCommand(shExe, forwardArgs...) } - // Signal to the extension that it is being run by gh rather than standalone, so it can - // adjust things like usage strings. - externalCmd.Env = append(externalCmd.Environ(), "GH_EXTENSION=1") + // Tell the extension that gh dispatched it and provide a reliable path back to this executable. + externalCmd.Env = append(externalCmd.Environ(), "GH_EXTENSION=1", "GH_PATH="+m.ghPath) externalCmd.Stdin = stdin externalCmd.Stdout = stdout diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 202f7ae556c..0b79b4b6fc8 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -271,7 +271,7 @@ func branchFunc(f *cmdutil.Factory) func() (string, error) { } func extensionManager(f *cmdutil.Factory) *extension.Manager { - em := extension.NewManager(f.IOStreams, f.GitClient) + em := extension.NewManager(f.IOStreams, f.GitClient, f.ExecutablePath) cfg, err := f.Config() if err != nil { From c708a53e5274ea16ca16d022bbed55f949b3bce0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:07:11 +0000 Subject: [PATCH 21/22] Remove GH_EXTENSION help text per review feedback Co-authored-by: williammartin <1611510+williammartin@users.noreply.github.com> --- acceptance/testdata/extension/extension-env.txtar | 4 ---- pkg/cmd/root/help_topic.go | 3 --- 2 files changed, 7 deletions(-) diff --git a/acceptance/testdata/extension/extension-env.txtar b/acceptance/testdata/extension/extension-env.txtar index f0368afe160..66bfa199940 100644 --- a/acceptance/testdata/extension/extension-env.txtar +++ b/acceptance/testdata/extension/extension-env.txtar @@ -36,10 +36,6 @@ stdout 'GH_PATH was set correctly' exec ./$EXT_DIR stdout 'GH_EXTENSION=0' -# Verify GH_EXTENSION is documented -exec gh help environment -stdout 'GH_EXTENSION`: set to `1` by gh when it invokes an extension' - -- print-env.sh -- #!/usr/bin/env bash set -e diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index 491750bbb4e..0becbf5c81b 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -99,9 +99,6 @@ var HelpTopics = []helpTopic{ When an extension is executed, gh checks for new versions for the executed extension once every 24 hours. If a newer version was found, an upgrade notice is displayed on standard error. - %[1]sGH_EXTENSION%[1]s: set to %[1]s1%[1]s by gh when it invokes an extension, allowing an extension to - tell whether it was run as %[1]sgh %[1]s or directly as a standalone program. - %[1]sGH_CONFIG_DIR%[1]s: the directory where gh will store configuration files. If not specified, the default value will be one of the following paths (in order of precedence): - %[1]s$XDG_CONFIG_HOME/gh%[1]s (if %[1]s$XDG_CONFIG_HOME%[1]s is set), From 983071ab7fc043e6ddce3bdcf0df41e0f4cf2155 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:18:07 +0000 Subject: [PATCH 22/22] Restore GH_EXTENSION and GH_PATH help entries Co-authored-by: williammartin <1611510+williammartin@users.noreply.github.com> --- acceptance/testdata/extension/extension-env.txtar | 5 +++++ pkg/cmd/root/help_topic.go | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/acceptance/testdata/extension/extension-env.txtar b/acceptance/testdata/extension/extension-env.txtar index 66bfa199940..2f733a5421b 100644 --- a/acceptance/testdata/extension/extension-env.txtar +++ b/acceptance/testdata/extension/extension-env.txtar @@ -36,6 +36,11 @@ stdout 'GH_PATH was set correctly' exec ./$EXT_DIR stdout 'GH_EXTENSION=0' +# Verify GH_EXTENSION and GH_PATH are documented +exec gh help environment +stdout 'GH_EXTENSION`: set to `1` by gh when it invokes an extension' +stdout 'GH_PATH`: set the path to the gh executable, useful for when gh can not properly determine' + -- print-env.sh -- #!/usr/bin/env bash set -e diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index 0becbf5c81b..c431c10489c 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -99,6 +99,9 @@ var HelpTopics = []helpTopic{ When an extension is executed, gh checks for new versions for the executed extension once every 24 hours. If a newer version was found, an upgrade notice is displayed on standard error. + %[1]sGH_EXTENSION%[1]s: set to %[1]s1%[1]s by gh when it invokes an extension, allowing an extension to + tell whether it was run as %[1]sgh %[1]s or directly as a standalone program. + %[1]sGH_CONFIG_DIR%[1]s: the directory where gh will store configuration files. If not specified, the default value will be one of the following paths (in order of precedence): - %[1]s$XDG_CONFIG_HOME/gh%[1]s (if %[1]s$XDG_CONFIG_HOME%[1]s is set), @@ -108,7 +111,8 @@ var HelpTopics = []helpTopic{ %[1]sGH_PROMPT_DISABLED%[1]s: set to any value to disable interactive prompting in the terminal. %[1]sGH_PATH%[1]s: set the path to the gh executable, useful for when gh can not properly determine - its own path such as in the cygwin terminal. + its own path such as in the cygwin terminal. gh also sets this when invoking extensions so they + can call back into the same gh executable. %[1]sGH_MDWIDTH%[1]s: default maximum width for markdown render wrapping. The max width of lines wrapped on the terminal will be taken as the lesser of the terminal width, this value, or 120 if