From cc3dd515a6dad6a965007bd95f7fcad0ffd27d6d Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Fri, 28 Feb 2025 12:25:49 +0500 Subject: [PATCH 001/147] Show host name in repo creation prompts --- pkg/cmd/repo/create/create.go | 28 ++++++++++++++++------- pkg/cmd/repo/create/create_test.go | 36 +++++++++++++++--------------- 2 files changed, 38 insertions(+), 26 deletions(-) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index cd7c56ea8..be0be2ddd 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -262,10 +262,15 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co func createRun(opts *CreateOptions) error { if opts.Interactive { + cfg, err := opts.Config() + if err != nil { + return err + } + host, _ := cfg.Authentication().DefaultHost() answer, err := opts.Prompter.Select("What would you like to do?", "", []string{ - "Create a new repository on GitHub from scratch", - "Create a new repository on GitHub from a template repository", - "Push an existing local repository to GitHub", + fmt.Sprintf("Create a new repository on %s from scratch", host), + fmt.Sprintf("Create a new repository on %s from a template repository", host), + fmt.Sprintf("Push an existing local repository to %s", host), }) if err != nil { return err @@ -323,7 +328,9 @@ func createFromScratch(opts *CreateOptions) error { if idx := strings.IndexRune(opts.Name, '/'); idx > 0 { targetRepo = opts.Name[0:idx+1] + shared.NormalizeRepoName(opts.Name[idx+1:]) } - confirmed, err := opts.Prompter.Confirm(fmt.Sprintf(`This will create "%s" as a %s repository on GitHub. Continue?`, targetRepo, strings.ToLower(opts.Visibility)), true) + confirmed, err := opts.Prompter.Confirm( + fmt.Sprintf(`This will create "%s" as a %s repository on %s. Continue?`, targetRepo, strings.ToLower(opts.Visibility), host), + true) if err != nil { return err } else if !confirmed { @@ -392,9 +399,10 @@ func createFromScratch(opts *CreateOptions) error { isTTY := opts.IO.IsStdoutTTY() if isTTY { fmt.Fprintf(opts.IO.Out, - "%s Created repository %s on GitHub\n %s\n", + "%s Created repository %s on %s\n %s\n", cs.SuccessIconWithColor(cs.Green), ghrepo.FullName(repo), + host, repo.URL) } else { fmt.Fprintln(opts.IO.Out, repo.URL) @@ -482,7 +490,9 @@ func createFromTemplate(opts *CreateOptions) error { if idx := strings.IndexRune(opts.Name, '/'); idx > 0 { targetRepo = opts.Name[0:idx+1] + shared.NormalizeRepoName(opts.Name[idx+1:]) } - confirmed, err := opts.Prompter.Confirm(fmt.Sprintf(`This will create "%s" as a %s repository on GitHub. Continue?`, targetRepo, strings.ToLower(opts.Visibility)), true) + confirmed, err := opts.Prompter.Confirm( + fmt.Sprintf(`This will create "%s" as a %s repository on %s. Continue?`, targetRepo, strings.ToLower(opts.Visibility), host), + true) if err != nil { return err } else if !confirmed { @@ -496,9 +506,10 @@ func createFromTemplate(opts *CreateOptions) error { cs := opts.IO.ColorScheme() fmt.Fprintf(opts.IO.Out, - "%s Created repository %s on GitHub\n %s\n", + "%s Created repository %s on %s\n %s\n", cs.SuccessIconWithColor(cs.Green), ghrepo.FullName(repo), + host, repo.URL) opts.Clone, err = opts.Prompter.Confirm("Clone the new repository locally?", true) @@ -622,9 +633,10 @@ func createFromLocal(opts *CreateOptions) error { if isTTY { fmt.Fprintf(stdout, - "%s Created repository %s on GitHub\n %s\n", + "%s Created repository %s on %s\n %s\n", cs.SuccessIconWithColor(cs.Green), ghrepo.FullName(repo), + host, repo.URL) } else { fmt.Fprintln(stdout, repo.URL) diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index c33cfdad6..760789786 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -201,7 +201,7 @@ func Test_createRun(t *testing.T) { name: "interactive create from scratch with gitignore and license", opts: &CreateOptions{Interactive: true}, tty: true, - wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n", + wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n", promptStubs: func(p *prompter.PrompterMock) { p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) { switch message { @@ -211,7 +211,7 @@ func Test_createRun(t *testing.T) { return true, nil case "Would you like to add a license?": return true, nil - case `This will create "REPO" as a private repository on GitHub. Continue?`: + case `This will create "REPO" as a private repository on github.com. Continue?`: return defaultValue, nil case "Clone the new repository locally?": return defaultValue, nil @@ -232,7 +232,7 @@ func Test_createRun(t *testing.T) { 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 from scratch") + return prompter.IndexFor(options, "Create a new repository on github.com from scratch") case "Visibility": return prompter.IndexFor(options, "Private") case "Choose a license": @@ -267,7 +267,7 @@ func Test_createRun(t *testing.T) { name: "interactive create from scratch but with prompted owner", opts: &CreateOptions{Interactive: true}, tty: true, - wantStdout: "✓ Created repository org1/REPO on GitHub\n https://github.com/org1/REPO\n", + wantStdout: "✓ Created repository org1/REPO on github.com\n https://github.com/org1/REPO\n", promptStubs: func(p *prompter.PrompterMock) { p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) { switch message { @@ -277,7 +277,7 @@ func Test_createRun(t *testing.T) { return false, nil case "Would you like to add a license?": return false, nil - case `This will create "org1/REPO" as a private repository on GitHub. Continue?`: + case `This will create "org1/REPO" as a private repository on github.com. Continue?`: return true, nil case "Clone the new repository locally?": return false, nil @@ -300,7 +300,7 @@ func Test_createRun(t *testing.T) { case "Repository owner": return prompter.IndexFor(options, "org1") case "What would you like to do?": - return prompter.IndexFor(options, "Create a new repository on GitHub from scratch") + return prompter.IndexFor(options, "Create a new repository on github.com from scratch") case "Visibility": return prompter.IndexFor(options, "Private") default: @@ -345,7 +345,7 @@ func Test_createRun(t *testing.T) { return false, nil case "Would you like to add a license?": return false, nil - case `This will create "REPO" as a private repository on GitHub. Continue?`: + case `This will create "REPO" as a private repository on github.com. Continue?`: return false, nil default: return false, fmt.Errorf("unexpected confirm prompt: %s", message) @@ -364,7 +364,7 @@ func Test_createRun(t *testing.T) { 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 from scratch") + return prompter.IndexFor(options, "Create a new repository on github.com from scratch") case "Visibility": return prompter.IndexFor(options, "Private") default: @@ -409,7 +409,7 @@ func Test_createRun(t *testing.T) { p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { switch message { case "What would you like to do?": - return prompter.IndexFor(options, "Push an existing local repository to GitHub") + return prompter.IndexFor(options, "Push an existing local repository to github.com") case "Visibility": return prompter.IndexFor(options, "Private") default: @@ -441,7 +441,7 @@ func Test_createRun(t *testing.T) { cs.Register(`git -C . rev-parse --git-dir`, 0, ".git") cs.Register(`git -C . rev-parse HEAD`, 0, "commithash") }, - wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n", + wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n", }, { name: "interactive with existing bare repository public and push", @@ -475,7 +475,7 @@ func Test_createRun(t *testing.T) { p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { switch message { case "What would you like to do?": - return prompter.IndexFor(options, "Push an existing local repository to GitHub") + return prompter.IndexFor(options, "Push an existing local repository to github.com") case "Visibility": return prompter.IndexFor(options, "Private") default: @@ -509,7 +509,7 @@ func Test_createRun(t *testing.T) { cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "") cs.Register(`git -C . push origin --mirror`, 0, "") }, - wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Mirrored all refs to https://github.com/OWNER/REPO.git\n", + wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Mirrored all refs to https://github.com/OWNER/REPO.git\n", }, { name: "interactive with existing repository public add remote and push", @@ -543,7 +543,7 @@ func Test_createRun(t *testing.T) { p.SelectFunc = func(message, defaultValue string, options []string) (int, error) { switch message { case "What would you like to do?": - return prompter.IndexFor(options, "Push an existing local repository to GitHub") + return prompter.IndexFor(options, "Push an existing local repository to github.com") case "Visibility": return prompter.IndexFor(options, "Private") default: @@ -577,7 +577,7 @@ func Test_createRun(t *testing.T) { cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "") cs.Register(`git -C . push --set-upstream origin HEAD`, 0, "") }, - wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Pushed commits to https://github.com/OWNER/REPO.git\n", + wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Pushed commits to https://github.com/OWNER/REPO.git\n", }, { name: "interactive create from a template repository", @@ -586,7 +586,7 @@ func Test_createRun(t *testing.T) { promptStubs: func(p *prompter.PrompterMock) { p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) { switch message { - case `This will create "OWNER/REPO" as a private repository on GitHub. Continue?`: + case `This will create "OWNER/REPO" as a private repository on github.com. Continue?`: return defaultValue, nil case "Clone the new repository locally?": return defaultValue, nil @@ -611,7 +611,7 @@ func Test_createRun(t *testing.T) { case "Choose a template repository": return prompter.IndexFor(options, "REPO") case "What would you like to do?": - return prompter.IndexFor(options, "Create a new repository on GitHub from a template repository") + return prompter.IndexFor(options, "Create a new repository on github.com from a template repository") case "Visibility": return prompter.IndexFor(options, "Private") default: @@ -654,7 +654,7 @@ func Test_createRun(t *testing.T) { execStubs: func(cs *run.CommandStubber) { cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 0, "") }, - wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n", + wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n", }, { name: "interactive create from template repo but there are no template repos", @@ -680,7 +680,7 @@ func Test_createRun(t *testing.T) { 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 from a template repository") + return prompter.IndexFor(options, "Create a new repository on github.com from a template repository") case "Visibility": return prompter.IndexFor(options, "Private") default: From a1136bf5cd51d0bd8fd99ca639f1096e2ee9af2e Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Sun, 9 Mar 2025 12:19:16 +0500 Subject: [PATCH 002/147] Add initial test --- pkg/cmd/repo/create/create_test.go | 90 +++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 760789786..5f1f17e60 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + ghmock "github.com/cli/cli/v2/internal/gh/mock" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" @@ -950,6 +951,88 @@ func Test_createRun(t *testing.T) { }, wantStdout: "https://github.com/OWNER/REPO\n", }, + { + name: "interactive create from scratch with host override", + opts: &CreateOptions{ + Interactive: true, + Config: func() (gh.Config, error) { + cfg := &ghmock.ConfigMock{ + AuthenticationFunc: func() gh.AuthConfig { + authCfg := &config.AuthConfig{} + authCfg.SetHosts([]string{"example.com"}) + authCfg.SetDefaultHost("example.com", "GH_HOST") + return authCfg + }, + } + return cfg, nil + }, + }, + tty: true, + promptStubs: func(p *prompter.PrompterMock) { + p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) { + switch message { + case "Would you like to add a README file?": + return false, nil + case "Would you like to add a .gitignore?": + return false, nil + case "Would you like to add a license?": + return false, nil + case `This will create "REPO" as a private repository on example.com. Continue?`: + return defaultValue, nil + case "Clone the new repository locally?": + return false, nil + default: + return false, fmt.Errorf("unexpected confirm prompt: %s", message) + } + } + p.InputFunc = func(message, defaultValue string) (string, error) { + switch message { + case "Repository name": + return "REPO", nil + case "Description": + return "my new repo", nil + default: + return "", fmt.Errorf("unexpected input prompt: %s", message) + } + } + 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 example.com from scratch") + case "Visibility": + return prompter.IndexFor(options, "Private") + case "Choose a license": + return prompter.IndexFor(options, "GNU Lesser General Public License v3.0") + case "Choose a .gitignore template": + return prompter.IndexFor(options, "Go") + default: + return 0, fmt.Errorf("unexpected select prompt: %s", message) + } + } + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"someuser","organizations":{"nodes": []}}}}`)) + reg.Register( + httpmock.GraphQL(`mutation RepositoryCreate\b`), + httpmock.StringResponse(` + { + "data": { + "createRepository": { + "repository": { + "id": "REPOID", + "name": "REPO", + "owner": {"login":"OWNER"}, + "url": "https://example.com/OWNER/REPO" + } + } + } + }`), + ) + }, + wantStdout: "✓ Created repository OWNER/REPO on example.com\n https://example.com/OWNER/REPO\n", + }, } for _, tt := range tests { prompterMock := &prompter.PrompterMock{} @@ -965,8 +1048,11 @@ func Test_createRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } - tt.opts.Config = func() (gh.Config, error) { - return config.NewBlankConfig(), nil + + if tt.opts.Config == nil { + tt.opts.Config = func() (gh.Config, error) { + return config.NewBlankConfig(), nil + } } tt.opts.GitClient = &git.Client{ From e02ee18ed2b23bd8c8a3141e622e953f5723368b Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Sat, 15 Mar 2025 17:55:29 +0500 Subject: [PATCH 003/147] [gh ext] Fix `GitKind` extension directory path --- pkg/cmd/extension/extension.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/extension/extension.go b/pkg/cmd/extension/extension.go index dc7ffa8c3..f30bf63c1 100644 --- a/pkg/cmd/extension/extension.go +++ b/pkg/cmd/extension/extension.go @@ -163,7 +163,8 @@ func (e *Extension) IsPinned() bool { isPinned = manifest.IsPinned } case GitKind: - pinPath := filepath.Join(e.Path(), fmt.Sprintf(".pin-%s", e.CurrentVersion())) + extDir := filepath.Dir(e.path) + pinPath := filepath.Join(extDir, fmt.Sprintf(".pin-%s", e.CurrentVersion())) if _, err := os.Stat(pinPath); err == nil { isPinned = true } else { From 202c1ad16b78aa681cbe552164f2e9a9a7e574fa Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:04:57 -0600 Subject: [PATCH 004/147] feat(prompter): add accessible prompter support --- go.mod | 12 ++ go.sum | 29 +++- internal/prompter/prompter.go | 233 ++++++++++++++++++++++++++++- internal/prompter/prompter_test.go | 63 ++++++++ 4 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 internal/prompter/prompter_test.go diff --git a/go.mod b/go.mod index bea712a2d..f64430db7 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,9 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/briandowns/spinner v1.18.1 github.com/cenkalti/backoff/v4 v4.3.0 + github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 + github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc github.com/cli/go-gh/v2 v2.12.0 github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 @@ -64,12 +66,16 @@ require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/alessio/shellescape v1.4.2 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/blang/semver v3.5.1+incompatible // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/charmbracelet/bubbles v0.20.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cli/browser v1.3.0 // indirect github.com/cli/shurcooL-graphql v0.0.4 // indirect @@ -82,6 +88,8 @@ require ( github.com/docker/cli v27.5.0+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.8.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.16.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gdamore/encoding v1.0.0 // indirect @@ -118,12 +126,16 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/oklog/ulid v1.3.1 // indirect diff --git a/go.sum b/go.sum index 2b5a31212..8ee1dcb09 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4u github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.32.8 h1:cZV+NUS/eGxKXMtmyhtYPJ7Z4YLoI/V8bkTdRZfYhGo= @@ -95,22 +97,32 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY= github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= +github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 h1:hx6E25SvI2WiZdt/gxINcYBnHD7PE2Vr9auqwg5B05g= github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3/go.mod h1:ihVqv4/YOY5Fweu1cxajuQrwJFh3zU4Ukb4mHVNjq3s= +github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= +github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc h1:nFRtCfZu/zkltd2lsLUPlVNv3ej/Atod9hcdbRZtlys= github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= -github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q= @@ -160,6 +172,10 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= @@ -340,6 +356,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -356,10 +374,16 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= @@ -550,6 +574,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 1d4b11cbc..7909bc8fd 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -2,9 +2,12 @@ package prompter import ( "fmt" + "os" "strings" "github.com/AlecAivazis/survey/v2" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/surveyext" ghPrompter "github.com/cli/go-gh/v2/pkg/prompter" @@ -27,15 +30,233 @@ type Prompter interface { } func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter { - return &surveyPrompter{ - prompter: ghPrompter.New(stdin, stdout, stderr), - stdin: stdin, - stdout: stdout, - stderr: stderr, - editorCmd: editorCmd, + accessiblePrompterValue := os.Getenv("GH_SCREENREADER_FRIENDLY") + switch accessiblePrompterValue { + case "", "false", "0": + return &surveyPrompter{ + prompter: ghPrompter.New(stdin, stdout, stderr), + stdin: stdin, + stdout: stdout, + stderr: stderr, + editorCmd: editorCmd, + } + default: + return &huhPrompter{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + editorCmd: editorCmd, + accessible: true, + } } } +type huhPrompter struct { + stdin ghPrompter.FileReader + stdout ghPrompter.FileWriter + stderr ghPrompter.FileWriter + editorCmd string + accessible bool +} + +// IsAccessible returns true if the huhPrompter was created in accessible mode. +func (p *huhPrompter) IsAccessible() bool { + return p.accessible +} + +func (p *huhPrompter) newForm(groups ...*huh.Group) *huh.Form { + return huh.NewForm(groups...). + WithTheme(huh.ThemeBase16()). + WithAccessible(p.accessible). + WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) +} + +func (p *huhPrompter) Select(prompt, _ string, options []string) (int, error) { + var result int + formOptions := []huh.Option[int]{} + for i, o := range options { + formOptions = append(formOptions, huh.NewOption(o, i)) + } + + form := p.newForm( + huh.NewGroup( + huh.NewSelect[int](). + Title(prompt). + Value(&result). + Options(formOptions...), + ), + ) + + err := form.Run() + return result, err +} + +func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { + var result []int + formOptions := make([]huh.Option[int], len(options)) + for i, o := range options { + formOptions[i] = huh.NewOption(o, i) + } + + form := p.newForm( + huh.NewGroup( + huh.NewMultiSelect[int](). + Title(prompt). + Value(&result). + Limit(len(options)). + Options(formOptions...), + ), + ) + + if err := form.Run(); err != nil { + return nil, err + } + + mid := len(result) / 2 + return result[:mid], nil +} + +func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { + result := defaultValue + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title(prompt). + Value(&result), + ), + ) + + err := form.Run() + return result, err +} + +func (p *huhPrompter) Password(prompt string) (string, error) { + var result string + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title(prompt). + Value(&result), + // This doesn't have any effect in accessible mode. + // EchoMode(huh.EchoModePassword), + ), + ) + + err := form.Run() + return result, err +} + +func (p *huhPrompter) Confirm(prompt string, _ bool) (bool, error) { + var result bool + form := p.newForm( + huh.NewGroup( + huh.NewConfirm(). + Title(prompt). + Value(&result), + ), + ) + if err := form.Run(); err != nil { + return false, err + } + return result, nil +} + +func (p *huhPrompter) AuthToken() (string, error) { + var result string + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title("Paste your authentication token:"). + Validate(func(input string) error { + if input == "" { + return fmt.Errorf("token is required") + } + return nil + }). + Value(&result), + // This doesn't have any effect in accessible mode. + // EchoMode(huh.EchoModePassword), + ), + ) + + err := form.Run() + return result, err +} + +func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { + var result string + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title(fmt.Sprintf("Type %q to confirm deletion", requiredValue)). + Validate(func(input string) error { + if input != requiredValue { + return fmt.Errorf("You entered: %q", input) + } + return nil + }). + Value(&result), + // This doesn't have any effect in accessible mode. + // EchoMode(huh.EchoModePassword), + ), + ) + + return form.Run() +} + +func (p *huhPrompter) InputHostname() (string, error) { + var result string + form := p.newForm( + huh.NewGroup( + huh.NewInput(). + Title("Hostname:"). + Validate(ghinstance.HostnameValidator). + Value(&result), + ), + ) + + err := form.Run() + return result, err +} + +func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { + var result string + form := p.newForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(prompt). + Options( + huh.NewOption("Open Editor", "open"), + huh.NewOption("Skip", "skip"), + ). + Value(&result), + ), + ) + if err := form.Run(); err != nil { + return "", err + } + + if result == "skip" { + // TODO: loop if blank not allowed + if !blankAllowed && defaultValue == "" { + panic("blank not allowed and no default value") + } + return "", nil + } + + text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr) + if err != nil { + return "", err + } + + // TODO: blank not allowed + if !blankAllowed && defaultValue == "" { + panic("blank not allowed and no default value") + } + + return text, nil +} + type surveyPrompter struct { prompter *ghPrompter.Prompter stdin ghPrompter.FileReader diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go new file mode 100644 index 000000000..99b8996ca --- /dev/null +++ b/internal/prompter/prompter_test.go @@ -0,0 +1,63 @@ +package prompter + +import ( + "testing" + + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" +) + +func TestNewReturnsAccessiblePrompter(t *testing.T) { + editorCmd := "nothing" + ios, _, _, _ := iostreams.Test() + stdin := ios.In + stdout := ios.Out + stderr := ios.ErrOut + + t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to true", func(t *testing.T) { + t.Parallel() + t.Setenv("GH_SCREENREADER_FRIENDLY", "true") + + p := New(editorCmd, stdin, stdout, stderr) + + assert.IsType(t, &huhPrompter{}, p, "expected huhPrompter to be returned") + assert.Equal(t, p.(*huhPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + }) + + t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { + t.Parallel() + t.Setenv("GH_SCREENREADER_FRIENDLY", "1") + + p := New(editorCmd, stdin, stdout, stderr) + + assert.IsType(t, &huhPrompter{}, p, "expected huhPrompter to be returned") + assert.Equal(t, p.(*huhPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + }) + + t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { + t.Parallel() + t.Setenv("GH_SCREENREADER_FRIENDLY", "false") + + p := New(editorCmd, stdin, stdout, stderr) + + assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") + }) + + t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to 0", func(t *testing.T) { + t.Parallel() + t.Setenv("GH_SCREENREADER_FRIENDLY", "0") + + p := New(editorCmd, stdin, stdout, stderr) + + assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") + }) + + t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is unset", func(t *testing.T) { + t.Parallel() + t.Setenv("GH_SCREENREADER_FRIENDLY", "") + + p := New(editorCmd, stdin, stdout, stderr) + + assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") + }) +} From 7b0c09541ddb41f8b355160bb413ede9eac42b8a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:52:26 -0600 Subject: [PATCH 005/147] feat(md prompter): md prompt respects blankAllowed Accessible prompter now respects blankAllowed and will not prompt for "skip" if blankAllowed is false. --- internal/prompter/prompter.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 7909bc8fd..c9af35fbf 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -221,23 +221,27 @@ func (p *huhPrompter) InputHostname() (string, error) { func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string + options := []huh.Option[string]{ + huh.NewOption("Open Editor", "open"), + } + if blankAllowed { + options = append(options, huh.NewOption("Skip", "skip")) + } + form := p.newForm( huh.NewGroup( huh.NewSelect[string](). Title(prompt). - Options( - huh.NewOption("Open Editor", "open"), - huh.NewOption("Skip", "skip"), - ). + Options(options...). Value(&result), ), ) + if err := form.Run(); err != nil { return "", err } if result == "skip" { - // TODO: loop if blank not allowed if !blankAllowed && defaultValue == "" { panic("blank not allowed and no default value") } From e973ee332dff7bcee46a99ab61fe5ff97dd9c12b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 31 Mar 2025 15:58:20 -0600 Subject: [PATCH 006/147] fix(md prompter): accessible prompt allows blank Allow the accessible markdownEditor prompt to be blank when the blank comes from the result of an interactive session with an editor, even when blankAllowed is false. This behavior aligns the accessible prompter with the behavior of the current standard prompter. --- internal/prompter/prompter.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index c9af35fbf..3e37834db 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -253,11 +253,6 @@ func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed b return "", err } - // TODO: blank not allowed - if !blankAllowed && defaultValue == "" { - panic("blank not allowed and no default value") - } - return text, nil } From 92b1a8e0f04ba7772ef538048a433f27a1a18475 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 31 Mar 2025 16:01:16 -0600 Subject: [PATCH 007/147] test(prompter): remove t.parallel calls t.Parallel() cannot be used when env vars are being set. --- internal/prompter/prompter_test.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index 99b8996ca..f0084e8fe 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -15,7 +15,6 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { stderr := ios.ErrOut t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to true", func(t *testing.T) { - t.Parallel() t.Setenv("GH_SCREENREADER_FRIENDLY", "true") p := New(editorCmd, stdin, stdout, stderr) @@ -25,7 +24,6 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { }) t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { - t.Parallel() t.Setenv("GH_SCREENREADER_FRIENDLY", "1") p := New(editorCmd, stdin, stdout, stderr) @@ -35,7 +33,6 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { - t.Parallel() t.Setenv("GH_SCREENREADER_FRIENDLY", "false") p := New(editorCmd, stdin, stdout, stderr) @@ -44,7 +41,6 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to 0", func(t *testing.T) { - t.Parallel() t.Setenv("GH_SCREENREADER_FRIENDLY", "0") p := New(editorCmd, stdin, stdout, stderr) @@ -53,7 +49,6 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is unset", func(t *testing.T) { - t.Parallel() t.Setenv("GH_SCREENREADER_FRIENDLY", "") p := New(editorCmd, stdin, stdout, stderr) From 88a98ea63a2694765c73b8887ca2ff905cb4c2c3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 11:58:10 -0600 Subject: [PATCH 008/147] feat(prompter): include `no` as false-y value Co-authored-by: Andy Feller --- internal/prompter/prompter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 3e37834db..abadda3dc 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -32,7 +32,7 @@ type Prompter interface { func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter { accessiblePrompterValue := os.Getenv("GH_SCREENREADER_FRIENDLY") switch accessiblePrompterValue { - case "", "false", "0": + case "", "false", "0", "no": return &surveyPrompter{ prompter: ghPrompter.New(stdin, stdout, stderr), stdin: stdin, From f7de9e0c1198ef5cc932a2483d2e4230741a6abb Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 12:53:44 -0600 Subject: [PATCH 009/147] test(prompter): `go-expect` based prompter tests --- go.mod | 2 + internal/prompter/prompter_test.go | 294 +++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+) diff --git a/go.mod b/go.mod index f64430db7..85b0f88d4 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.23.5 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc v1.0.0 + github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 github.com/briandowns/spinner v1.18.1 github.com/cenkalti/backoff/v4 v4.3.0 github.com/charmbracelet/bubbletea v1.3.4 @@ -31,6 +32,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-version v1.3.0 github.com/henvic/httpretty v0.1.4 + github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec github.com/in-toto/attestation v1.1.1 github.com/joho/godotenv v1.5.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index f0084e8fe..1e66d7471 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -1,10 +1,19 @@ package prompter import ( + "fmt" + "io" + "os" + "strings" "testing" + "time" + "github.com/Netflix/go-expect" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/creack/pty" + "github.com/hinshun/vt10x" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewReturnsAccessiblePrompter(t *testing.T) { @@ -56,3 +65,288 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") }) } + +func TestAccessibleHuhprompter(t *testing.T) { + // Create a PTY and hook up a virtual terminal emulator + ptm, pts, err := pty.Open() + require.NoError(t, err) + + term := vt10x.New(vt10x.WithWriter(pts)) + + // Create a console via Expect that allows scripting against the terminal + consoleOpts := []expect.ConsoleOpt{ + expect.WithStdin(ptm), + expect.WithStdout(term), + expect.WithCloser(ptm, pts), + failOnExpectError(t), + failOnSendError(t), + expect.WithDefaultTimeout(time.Second * 600), + } + + console, err := expect.NewConsole(consoleOpts...) + require.NoError(t, err) + t.Cleanup(func() { testCloser(t, console) }) + + p := &huhPrompter{ + editorCmd: "", // intentionally empty to cause a failure. + accessible: true, + } + + // Using OS here because huh currently ignores configured iostreams + // See https://github.com/charmbracelet/huh/issues/612 + stdIn := os.Stdin + stdOut := os.Stdout + stdErr := os.Stderr + + t.Cleanup(func() { + os.Stdin = stdIn + os.Stdout = stdOut + os.Stderr = stdErr + }) + + os.Stdin = console.Tty() + os.Stdout = console.Tty() + os.Stderr = console.Tty() + + t.Run("Select", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Choose:") + require.NoError(t, err) + + // Select option 1 + _, err = console.SendLine("1") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) + require.NoError(t, err) + + assert.Equal(t, 0, selectValue) + }) + + t.Run("MultiSelect", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select a number") + require.NoError(t, err) + + // Select options 1 and 2 + _, err = console.SendLine("1") + require.NoError(t, err) + _, err = console.SendLine("2") + require.NoError(t, err) + + // This confirms selections + _, err = console.SendLine("0") + require.NoError(t, err) + }() + + multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"}) + require.NoError(t, err) + + assert.Equal(t, []int{0, 1}, multiSelectValue) + }) + + t.Run("Input", func(t *testing.T) { + dummyText := "12345abcdefg" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter some characters") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine(dummyText) + require.NoError(t, err) + }() + + inputValue, err := p.Input("Enter some characters", "") + require.NoError(t, err) + + assert.Equal(t, dummyText, inputValue) + }) + + t.Run("Password", func(t *testing.T) { + dummyPassword := "12345abcdefg" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter password") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine(dummyPassword) + require.NoError(t, err) + }() + + passwordValue, err := p.Password("Enter password") + require.NoError(t, err) + require.Equal(t, dummyPassword, passwordValue) + }) + + t.Run("Confirm", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Are you sure") + require.NoError(t, err) + + // Confirm + _, err = console.SendLine("y") + require.NoError(t, err) + }() + + confirmValue, err := p.Confirm("Are you sure", false) + require.NoError(t, err) + require.Equal(t, true, confirmValue) + }) + + t.Run("AuthToken", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Paste your authentication token:") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine("12345abcdefg") + require.NoError(t, err) + }() + + authValue, err := p.AuthToken() + require.NoError(t, err) + require.Equal(t, "12345abcdefg", authValue) + }) + + t.Run("ConfirmDeletion", func(t *testing.T) { + requiredValue := "test" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) + require.NoError(t, err) + + // Confirm + _, err = console.SendLine(requiredValue) + require.NoError(t, err) + }() + + // An err indicates that the confirmation text sent did not match + err := p.ConfirmDeletion(requiredValue) + require.NoError(t, err) + }) + + t.Run("InputHostname", func(t *testing.T) { + var inputValue string + hostname := "somethingdoesnotmatter.com" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Hostname:") + require.NoError(t, err) + + // Enter the hostname + _, err = console.SendLine(hostname) + require.NoError(t, err) + }() + + inputValue, err := p.InputHostname() + require.NoError(t, err) + require.Equal(t, hostname, inputValue) + }) + + t.Run("MarkdownEditor - blank allowed", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("How to edit?") + require.NoError(t, err) + + // Enter 2, to select "skip" + _, err = console.SendLine("2") + require.NoError(t, err) + }() + + inputValue, err := p.MarkdownEditor("How to edit?", "", true) + require.NoError(t, err) + require.Equal(t, "", inputValue) + }) + + t.Run("MarkdownEditor - blank disallowed", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("How to edit?") + require.NoError(t, err) + + // Enter number 2 to select "skip". This shoudln't be allowed. + _, err = console.SendLine("2") + require.NoError(t, err) + + // Expect a notice to enter something valid since blank is disallowed. + _, err = console.ExpectString("invalid input. please try again") + require.NoError(t, err) + + // Send a 1 to select to open the editor. + // Sending the input won't fail, so we expect no error here. + // See below though, since we expect the editor to fail to open. + _, err = console.SendLine("1") + require.NoError(t, err) + }() + + // However, here we do expect an error because the editor program + // is intentionally empty and will fail. + inputValue, err := p.MarkdownEditor("How to edit?", "", false) + require.Error(t, err) + require.Equal(t, "", inputValue) + }) +} + +// failOnExpectError adds an observer that will fail the test in a standardised way +// if any expectation on the command output fails, without requiring an explicit +// assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnExpectError(t testing.TB) expect.ConsoleOpt { + t.Helper() + return expect.WithExpectObserver( + func(matchers []expect.Matcher, buf string, err error) { + t.Helper() + + if err == nil { + return + } + + if len(matchers) == 0 { + t.Fatalf("Error occurred while matching %q: %s\n", buf, err) + } + + var criteria []string + for _, matcher := range matchers { + criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria())) + } + t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err) + }, + ) +} + +// failOnSendError adds an observer that will fail the test in a standardised way +// if any sending of input fails, without requiring an explicit assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnSendError(t testing.TB) expect.ConsoleOpt { + t.Helper() + return expect.WithSendObserver( + func(msg string, n int, err error) { + t.Helper() + + if err != nil { + t.Fatalf("Failed to send %q: %s\n", msg, err) + } + if len(msg) != n { + t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg) + } + }, + ) +} + +// testCloser is a helper to fail the test if a Closer fails to close. +func testCloser(t testing.TB, closer io.Closer) { + t.Helper() + if err := closer.Close(); err != nil { + t.Errorf("Close failed: %s", err) + } +} From 94bbd26aab390c3f8929b040ec229484b9685bd3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 12:54:35 -0600 Subject: [PATCH 010/147] fix(prompter): rename huhprompter --- internal/prompter/prompter.go | 26 +++++++++++++------------- internal/prompter/prompter_test.go | 10 +++++----- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index abadda3dc..93d1d34c0 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -41,7 +41,7 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr editorCmd: editorCmd, } default: - return &huhPrompter{ + return &SpeechSynthesizerFriendlyPrompter{ stdin: stdin, stdout: stdout, stderr: stderr, @@ -51,7 +51,7 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr } } -type huhPrompter struct { +type SpeechSynthesizerFriendlyPrompter struct { stdin ghPrompter.FileReader stdout ghPrompter.FileWriter stderr ghPrompter.FileWriter @@ -60,18 +60,18 @@ type huhPrompter struct { } // IsAccessible returns true if the huhPrompter was created in accessible mode. -func (p *huhPrompter) IsAccessible() bool { +func (p *SpeechSynthesizerFriendlyPrompter) IsAccessible() bool { return p.accessible } -func (p *huhPrompter) newForm(groups ...*huh.Group) *huh.Form { +func (p *SpeechSynthesizerFriendlyPrompter) newForm(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). WithTheme(huh.ThemeBase16()). WithAccessible(p.accessible). WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) } -func (p *huhPrompter) Select(prompt, _ string, options []string) (int, error) { +func (p *SpeechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []string) (int, error) { var result int formOptions := []huh.Option[int]{} for i, o := range options { @@ -91,7 +91,7 @@ func (p *huhPrompter) Select(prompt, _ string, options []string) (int, error) { return result, err } -func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { +func (p *SpeechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { var result []int formOptions := make([]huh.Option[int], len(options)) for i, o := range options { @@ -116,7 +116,7 @@ func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []st return result[:mid], nil } -func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { +func (p *SpeechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) (string, error) { result := defaultValue form := p.newForm( huh.NewGroup( @@ -130,7 +130,7 @@ func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { return result, err } -func (p *huhPrompter) Password(prompt string) (string, error) { +func (p *SpeechSynthesizerFriendlyPrompter) Password(prompt string) (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -146,7 +146,7 @@ func (p *huhPrompter) Password(prompt string) (string, error) { return result, err } -func (p *huhPrompter) Confirm(prompt string, _ bool) (bool, error) { +func (p *SpeechSynthesizerFriendlyPrompter) Confirm(prompt string, _ bool) (bool, error) { var result bool form := p.newForm( huh.NewGroup( @@ -161,7 +161,7 @@ func (p *huhPrompter) Confirm(prompt string, _ bool) (bool, error) { return result, nil } -func (p *huhPrompter) AuthToken() (string, error) { +func (p *SpeechSynthesizerFriendlyPrompter) AuthToken() (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -183,7 +183,7 @@ func (p *huhPrompter) AuthToken() (string, error) { return result, err } -func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { +func (p *SpeechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { var result string form := p.newForm( huh.NewGroup( @@ -204,7 +204,7 @@ func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { return form.Run() } -func (p *huhPrompter) InputHostname() (string, error) { +func (p *SpeechSynthesizerFriendlyPrompter) InputHostname() (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -219,7 +219,7 @@ func (p *huhPrompter) InputHostname() (string, error) { return result, err } -func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { +func (p *SpeechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string options := []huh.Option[string]{ huh.NewOption("Open Editor", "open"), diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index 1e66d7471..e610b3a7c 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -28,8 +28,8 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &huhPrompter{}, p, "expected huhPrompter to be returned") - assert.Equal(t, p.(*huhPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + assert.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") + assert.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") }) t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { @@ -37,8 +37,8 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &huhPrompter{}, p, "expected huhPrompter to be returned") - assert.Equal(t, p.(*huhPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + assert.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") + assert.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { @@ -87,7 +87,7 @@ func TestAccessibleHuhprompter(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { testCloser(t, console) }) - p := &huhPrompter{ + p := &SpeechSynthesizerFriendlyPrompter{ editorCmd: "", // intentionally empty to cause a failure. accessible: true, } From 0d7fd36f11ead4d35d09b51a00ba70ac54d5131d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 12:58:44 -0600 Subject: [PATCH 011/147] test(prompter): replace assert with require --- internal/prompter/prompter_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index e610b3a7c..debaa4496 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -28,8 +28,8 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") - assert.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") + require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") }) t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { @@ -37,8 +37,8 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") - assert.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") + require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { @@ -46,7 +46,7 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") + require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to 0", func(t *testing.T) { @@ -54,7 +54,7 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") + require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is unset", func(t *testing.T) { @@ -62,7 +62,7 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - assert.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") + require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") }) } From e42af358392fae34eab89053c76e286a10653815 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:00:46 -0600 Subject: [PATCH 012/147] tests(prompter): rename huhprompter --- internal/prompter/prompter_test.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index debaa4496..c8f6dd213 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -23,22 +23,22 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { stdout := ios.Out stderr := ios.ErrOut - t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to true", func(t *testing.T) { + t.Run("returns SpeechSynthesizerFriendlyPrompter when GH_SCREENREADER_FRIENDLY is set to true", func(t *testing.T) { t.Setenv("GH_SCREENREADER_FRIENDLY", "true") p := New(editorCmd, stdin, stdout, stderr) - require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") - require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") + require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") }) - t.Run("returns accessible huhPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { + t.Run("returns SpeechSynthesizerFriendlyPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { t.Setenv("GH_SCREENREADER_FRIENDLY", "1") p := New(editorCmd, stdin, stdout, stderr) - require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected huhPrompter to be returned") - require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected huhPrompter to be accessible") + require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") + require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { @@ -66,7 +66,7 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { }) } -func TestAccessibleHuhprompter(t *testing.T) { +func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { // Create a PTY and hook up a virtual terminal emulator ptm, pts, err := pty.Open() require.NoError(t, err) From e299b56c0f29713c853df03abc5ff5f1f306c155 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:08:36 -0600 Subject: [PATCH 013/147] test(prompter): remove needless variable declaration --- internal/prompter/prompter_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index c8f6dd213..5c4a45a2c 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -233,7 +233,6 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { }) t.Run("InputHostname", func(t *testing.T) { - var inputValue string hostname := "somethingdoesnotmatter.com" go func() { // Wait for prompt to appear From 3eca268a7f60f909afe8ad4cb77b2708d94a2525 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Wed, 2 Apr 2025 18:24:20 -0400 Subject: [PATCH 014/147] Introduce `color_labels` support, update commands This commit implements the actual changes around configuration setting / environment variable logic for displaying labels using their RGB hex color code in terminals with truecolor support. One of the subtler changes in this commit is renaming generic ColorScheme.HexToRGB logic to render truecolor to ColorScheme.Label as this feature was being used exclusively for labels. This is due to confusion about introducing the new `color_labels` config on top of generic coloring logic. --- internal/config/config.go | 17 +++++++ internal/config/config_test.go | 2 + internal/config/stub.go | 3 ++ internal/gh/gh.go | 2 + internal/gh/mock/config.go | 44 +++++++++++++++++ pkg/cmd/config/list/list_test.go | 19 +++++--- pkg/cmd/factory/default.go | 6 +++ pkg/cmd/factory/default_test.go | 47 ++++++++++++++++++ pkg/cmd/gist/list/list_test.go | 2 +- pkg/cmd/issue/shared/display.go | 2 +- pkg/cmd/issue/view/view.go | 2 +- pkg/cmd/label/list.go | 7 ++- pkg/cmd/pr/view/view.go | 2 +- pkg/cmd/search/shared/shared.go | 2 +- pkg/iostreams/color.go | 20 +++----- pkg/iostreams/color_test.go | 83 +++++++++----------------------- pkg/iostreams/iostreams.go | 11 ++++- 17 files changed, 183 insertions(+), 88 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 476154d66..e7534dfdb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ import ( const ( aliasesKey = "aliases" browserKey = "browser" + colorLabelsKey = "color_labels" editorKey = "editor" gitProtocolKey = "git_protocol" hostsKey = "hosts" @@ -113,6 +114,11 @@ func (c *cfg) Browser(hostname string) gh.ConfigEntry { return c.GetOrDefault(hostname, browserKey).Unwrap() } +func (c *cfg) ColorLabels(hostname string) gh.ConfigEntry { + // Intentionally panic if there is no user provided value or default value (which would be a programmer error) + return c.GetOrDefault(hostname, colorLabelsKey).Unwrap() +} + func (c *cfg) Editor(hostname string) gh.ConfigEntry { // Intentionally panic if there is no user provided value or default value (which would be a programmer error) return c.GetOrDefault(hostname, editorKey).Unwrap() @@ -532,6 +538,8 @@ aliases: http_unix_socket: # What web browser gh should use when opening URLs. If blank, will refer to environment. browser: +# Whether to display labels using their RGB hex color codes in terminals that support truecolor. Supported values: enabled, disabled +color_labels: disabled ` type ConfigOption struct { @@ -602,6 +610,15 @@ var Options = []ConfigOption{ return c.Browser(hostname).Value }, }, + { + Key: colorLabelsKey, + Description: "whether to display labels using their RGB hex color codes in terminals that support truecolor", + DefaultValue: "disabled", + AllowedValues: []string{"enabled", "disabled"}, + CurrentValue: func(c gh.Config, hostname string) string { + return c.ColorLabels(hostname).Value + }, + }, } func HomeDirPath(subdir string) (string, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fef87ddc6..67a9a98d1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -32,6 +32,7 @@ func TestNewConfigProvidesFallback(t *testing.T) { requireKeyWithValue(t, spiedCfg, []string{aliasesKey, "co"}, "pr checkout") requireKeyWithValue(t, spiedCfg, []string{httpUnixSocketKey}, "") requireKeyWithValue(t, spiedCfg, []string{browserKey}, "") + requireKeyWithValue(t, spiedCfg, []string{colorLabelsKey}, "disabled") } func TestGetOrDefaultApplicationDefaults(t *testing.T) { @@ -137,6 +138,7 @@ func TestFallbackConfig(t *testing.T) { requireKeyWithValue(t, cfg, []string{aliasesKey, "co"}, "pr checkout") requireKeyWithValue(t, cfg, []string{httpUnixSocketKey}, "") requireKeyWithValue(t, cfg, []string{browserKey}, "") + requireKeyWithValue(t, cfg, []string{colorLabelsKey}, "disabled") requireNoKey(t, cfg, []string{"unknown"}) } diff --git a/internal/config/stub.go b/internal/config/stub.go index 71d44556d..78073da4a 100644 --- a/internal/config/stub.go +++ b/internal/config/stub.go @@ -55,6 +55,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock { mock.BrowserFunc = func(hostname string) gh.ConfigEntry { return cfg.Browser(hostname) } + mock.ColorLabelsFunc = func(hostname string) gh.ConfigEntry { + return cfg.ColorLabels(hostname) + } mock.EditorFunc = func(hostname string) gh.ConfigEntry { return cfg.Editor(hostname) } diff --git a/internal/gh/gh.go b/internal/gh/gh.go index 8e640c41a..8f94d7f0d 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -37,6 +37,8 @@ type Config interface { // Browser returns the configured browser, optionally scoped by host. Browser(hostname string) ConfigEntry + // ColorLabels returns the configured colorize labels setting, optionally scoped by host. + ColorLabels(hostname string) ConfigEntry // Editor returns the configured editor, optionally scoped by host. Editor(hostname string) ConfigEntry // GitProtocol returns the configured git protocol, optionally scoped by host. diff --git a/internal/gh/mock/config.go b/internal/gh/mock/config.go index 569af1fac..b94cb084d 100644 --- a/internal/gh/mock/config.go +++ b/internal/gh/mock/config.go @@ -31,6 +31,9 @@ var _ gh.Config = &ConfigMock{} // CacheDirFunc: func() string { // panic("mock out the CacheDir method") // }, +// ColorLabelsFunc: func(hostname string) gh.ConfigEntry { +// panic("mock out the ColorLabels method") +// }, // EditorFunc: func(hostname string) gh.ConfigEntry { // panic("mock out the Editor method") // }, @@ -83,6 +86,9 @@ type ConfigMock struct { // CacheDirFunc mocks the CacheDir method. CacheDirFunc func() string + // ColorLabelsFunc mocks the ColorLabels method. + ColorLabelsFunc func(hostname string) gh.ConfigEntry + // EditorFunc mocks the Editor method. EditorFunc func(hostname string) gh.ConfigEntry @@ -132,6 +138,11 @@ type ConfigMock struct { // CacheDir holds details about calls to the CacheDir method. CacheDir []struct { } + // ColorLabels holds details about calls to the ColorLabels method. + ColorLabels []struct { + // Hostname is the hostname argument value. + Hostname string + } // Editor holds details about calls to the Editor method. Editor []struct { // Hostname is the hostname argument value. @@ -194,6 +205,7 @@ type ConfigMock struct { lockAuthentication sync.RWMutex lockBrowser sync.RWMutex lockCacheDir sync.RWMutex + lockColorLabels sync.RWMutex lockEditor sync.RWMutex lockGetOrDefault sync.RWMutex lockGitProtocol sync.RWMutex @@ -320,6 +332,38 @@ func (mock *ConfigMock) CacheDirCalls() []struct { return calls } +// ColorLabels calls ColorLabelsFunc. +func (mock *ConfigMock) ColorLabels(hostname string) gh.ConfigEntry { + if mock.ColorLabelsFunc == nil { + panic("ConfigMock.ColorLabelsFunc: method is nil but Config.ColorLabels was just called") + } + callInfo := struct { + Hostname string + }{ + Hostname: hostname, + } + mock.lockColorLabels.Lock() + mock.calls.ColorLabels = append(mock.calls.ColorLabels, callInfo) + mock.lockColorLabels.Unlock() + return mock.ColorLabelsFunc(hostname) +} + +// ColorLabelsCalls gets all the calls that were made to ColorLabels. +// Check the length with: +// +// len(mockedConfig.ColorLabelsCalls()) +func (mock *ConfigMock) ColorLabelsCalls() []struct { + Hostname string +} { + var calls []struct { + Hostname string + } + mock.lockColorLabels.RLock() + calls = mock.calls.ColorLabels + mock.lockColorLabels.RUnlock() + return calls +} + // Editor calls EditorFunc. func (mock *ConfigMock) Editor(hostname string) gh.ConfigEntry { if mock.EditorFunc == nil { diff --git a/pkg/cmd/config/list/list_test.go b/pkg/cmd/config/list/list_test.go index 65f83d659..2184d0f16 100644 --- a/pkg/cmd/config/list/list_test.go +++ b/pkg/cmd/config/list/list_test.go @@ -4,6 +4,7 @@ import ( "bytes" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/pkg/cmdutil" @@ -91,14 +92,16 @@ func Test_listRun(t *testing.T) { return cfg }(), input: &ListOptions{Hostname: "HOST"}, - stdout: `git_protocol=ssh -editor=/usr/bin/vim -prompt=disabled -prefer_editor_prompt=enabled -pager=less -http_unix_socket= -browser=brave -`, + stdout: heredoc.Doc(` + git_protocol=ssh + editor=/usr/bin/vim + prompt=disabled + prefer_editor_prompt=enabled + pager=less + http_unix_socket= + browser=brave + color_labels=disabled + `), }, } diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 923651487..5e9c25ab2 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -292,6 +292,12 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams { io.SetPager(pager.Value) } + if _, ghColorLabels := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabels { + io.SetColorLabels(true) // TODO: should this be a truthy value? + } else if prompt := cfg.ColorLabels(""); prompt.Value == "enabled" { + io.SetColorLabels(true) + } + return io } diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index 94955bb30..b1730d6e6 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -432,6 +432,49 @@ func Test_ioStreams_prompt(t *testing.T) { } } +func Test_ioStreams_colorLabels(t *testing.T) { + tests := []struct { + name string + config gh.Config + colorLabelsEnabled bool + env map[string]string + }{ + { + name: "default config", + colorLabelsEnabled: false, + }, + { + name: "config with colorLabels enabled", + config: enableColorLabelsConfig(), + colorLabelsEnabled: true, + }, + { + name: "colorLabels enabled via GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "1"}, + colorLabelsEnabled: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != nil { + for k, v := range tt.env { + t.Setenv(k, v) + } + } + f := New("1") + f.Config = func() (gh.Config, error) { + if tt.config == nil { + return config.NewBlankConfig(), nil + } else { + return tt.config, nil + } + } + io := ioStreams(f) + assert.Equal(t, tt.colorLabelsEnabled, io.ColorLabels()) + }) + } +} + func TestSSOURL(t *testing.T) { tests := []struct { name string @@ -537,3 +580,7 @@ func pagerConfig() gh.Config { func disablePromptConfig() gh.Config { return config.NewFromString("prompt: disabled") } + +func enableColorLabelsConfig() gh.Config { + return config.NewFromString("color_labels: enabled") +} diff --git a/pkg/cmd/gist/list/list_test.go b/pkg/cmd/gist/list/list_test.go index 4f6c8a9f7..1b55478b3 100644 --- a/pkg/cmd/gist/list/list_test.go +++ b/pkg/cmd/gist/list/list_test.go @@ -694,7 +694,7 @@ func Test_highlightMatch(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cs := iostreams.NewColorScheme(tt.color, false, false, iostreams.NoTheme) + cs := iostreams.NewColorScheme(tt.color, false, false, false, iostreams.NoTheme) matched := false got, err := highlightMatch(tt.input, regex, &matched, cs.Blue, cs.Highlight) diff --git a/pkg/cmd/issue/shared/display.go b/pkg/cmd/issue/shared/display.go index 0c56ffd2c..bfb9fc2c6 100644 --- a/pkg/cmd/issue/shared/display.go +++ b/pkg/cmd/issue/shared/display.go @@ -56,7 +56,7 @@ func issueLabelList(issue *api.Issue, cs *iostreams.ColorScheme, colorize bool) labelNames := make([]string, 0, len(issue.Labels.Nodes)) for _, label := range issue.Labels.Nodes { if colorize { - labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name)) + labelNames = append(labelNames, cs.Label(label.Color, label.Name)) } else { labelNames = append(labelNames, label.Name) } diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index b188c6a4c..e2420a820 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -317,7 +317,7 @@ func issueLabelList(issue *api.Issue, cs *iostreams.ColorScheme) string { if cs == nil { labelNames[i] = label.Name } else { - labelNames[i] = cs.HexToRGB(label.Color, label.Name) + labelNames[i] = cs.Label(label.Color, label.Name) } } diff --git a/pkg/cmd/label/list.go b/pkg/cmd/label/list.go index fe1e9cdb7..4fe2f5ec9 100644 --- a/pkg/cmd/label/list.go +++ b/pkg/cmd/label/list.go @@ -137,7 +137,12 @@ func printLabels(io *iostreams.IOStreams, labels []label) error { table := tableprinter.New(io, tableprinter.WithHeader("NAME", "DESCRIPTION", "COLOR")) for _, label := range labels { - table.AddField(label.Name, tableprinter.WithColor(cs.ColorFromRGB(label.Color))) + // Colorize the label using tableprinter's WithColor function for it to handle non-TTY situations + labelColor := tableprinter.WithColor(func(s string) string { + return cs.Label(label.Color, s) + }) + + table.AddField(label.Name, labelColor) table.AddField(label.Description) table.AddField("#" + label.Color) diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index ccad4fa77..719a8fc7b 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -423,7 +423,7 @@ func prLabelList(pr api.PullRequest, cs *iostreams.ColorScheme) string { labelNames := make([]string, 0, len(pr.Labels.Nodes)) for _, label := range pr.Labels.Nodes { - labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name)) + labelNames = append(labelNames, cs.Label(label.Color, label.Name)) } list := strings.Join(labelNames, ", ") diff --git a/pkg/cmd/search/shared/shared.go b/pkg/cmd/search/shared/shared.go index f0a346fc8..1e3d0069b 100644 --- a/pkg/cmd/search/shared/shared.go +++ b/pkg/cmd/search/shared/shared.go @@ -158,7 +158,7 @@ func listIssueLabels(issue *search.Issue, cs *iostreams.ColorScheme, colorize bo labelNames := make([]string, 0, len(issue.Labels)) for _, label := range issue.Labels { if colorize { - labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name)) + labelNames = append(labelNames, cs.Label(label.Color, label.Name)) } else { labelNames = append(labelNames, label.Name) } diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index 07fdcd79f..49f9496f5 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -41,12 +41,13 @@ var ( // NewColorScheme initializes color logic based on provided terminal capabilities. // Logic dealing with terminal theme detected, such as whether color is enabled, 8-bit color supported, true color supported, -// and terminal theme detected. -func NewColorScheme(enabled, is256enabled, trueColor bool, theme string) *ColorScheme { +// labels are colored, and terminal theme detected. +func NewColorScheme(enabled, is256enabled, trueColor, colorLabels bool, theme string) *ColorScheme { return &ColorScheme{ enabled: enabled, is256enabled: is256enabled, hasTrueColor: trueColor, + colorLabels: colorLabels, theme: theme, } } @@ -55,6 +56,7 @@ type ColorScheme struct { enabled bool is256enabled bool hasTrueColor bool + colorLabels bool theme string } @@ -240,17 +242,9 @@ func (c *ColorScheme) ColorFromString(s string) func(string) string { return fn } -// ColorFromRGB returns a function suitable for TablePrinter.AddField -// that calls HexToRGB, coloring text if supported by the terminal. -func (c *ColorScheme) ColorFromRGB(hex string) func(string) string { - return func(s string) string { - return c.HexToRGB(hex, s) - } -} - -// HexToRGB uses the given hex to color x if supported by the terminal. -func (c *ColorScheme) HexToRGB(hex string, x string) string { - if !c.enabled || !c.hasTrueColor || len(hex) != 6 { +// Label stylizes text based on label's RGB hex color. +func (c *ColorScheme) Label(hex string, x string) string { + if !c.enabled || !c.hasTrueColor || !c.colorLabels || len(hex) != 6 { return x } diff --git a/pkg/iostreams/color_test.go b/pkg/iostreams/color_test.go index b35c2eb73..c477891ce 100644 --- a/pkg/iostreams/color_test.go +++ b/pkg/iostreams/color_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestColorFromRGB(t *testing.T) { +func TestLabel(t *testing.T) { tests := []struct { name string hex string @@ -20,77 +20,40 @@ func TestColorFromRGB(t *testing.T) { hex: "fc0303", text: "red", wants: "\033[38;2;252;3;3mred\033[0m", - cs: NewColorScheme(true, true, true, NoTheme), + cs: NewColorScheme(true, true, true, true, NoTheme), }, { name: "no truecolor", hex: "fc0303", text: "red", wants: "red", - cs: NewColorScheme(true, true, false, NoTheme), + cs: NewColorScheme(true, true, false, true, NoTheme), }, { name: "no color", hex: "fc0303", text: "red", wants: "red", - cs: NewColorScheme(false, false, false, NoTheme), + cs: NewColorScheme(false, false, false, true, NoTheme), }, { name: "invalid hex", hex: "fc0", text: "red", wants: "red", - cs: NewColorScheme(false, false, false, NoTheme), + cs: NewColorScheme(false, false, false, true, NoTheme), + }, + { + name: "no color labels", + hex: "fc0303", + text: "red", + wants: "red", + cs: NewColorScheme(true, true, true, false, NoTheme), }, } for _, tt := range tests { - fn := tt.cs.ColorFromRGB(tt.hex) - assert.Equal(t, tt.wants, fn(tt.text)) - } -} - -func TestHexToRGB(t *testing.T) { - tests := []struct { - name string - hex string - text string - wants string - cs *ColorScheme - }{ - { - name: "truecolor", - hex: "fc0303", - text: "red", - wants: "\033[38;2;252;3;3mred\033[0m", - cs: NewColorScheme(true, true, true, NoTheme), - }, - { - name: "no truecolor", - hex: "fc0303", - text: "red", - wants: "red", - cs: NewColorScheme(true, true, false, NoTheme), - }, - { - name: "no color", - hex: "fc0303", - text: "red", - wants: "red", - cs: NewColorScheme(false, false, false, NoTheme), - }, - { - name: "invalid hex", - hex: "fc0", - text: "red", - wants: "red", - cs: NewColorScheme(false, false, false, NoTheme), - }, - } - - for _, tt := range tests { - output := tt.cs.HexToRGB(tt.hex, tt.text) + output := tt.cs.Label(tt.hex, tt.text) assert.Equal(t, tt.wants, output) } } @@ -109,61 +72,61 @@ func TestTableHeader(t *testing.T) { }{ { name: "when color is disabled, text is not stylized", - cs: NewColorScheme(false, false, false, NoTheme), + cs: NewColorScheme(false, false, false, false, NoTheme), input: "this should not be stylized", expected: "this should not be stylized", }, { name: "when 4-bit color is enabled but no theme, 4-bit default color and underline are used", - cs: NewColorScheme(true, false, false, NoTheme), + cs: NewColorScheme(true, false, false, false, NoTheme), input: "this should have no explicit color but underlined", expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset), }, { name: "when 4-bit color is enabled and theme is light, 4-bit dark color and underline are used", - cs: NewColorScheme(true, false, false, LightTheme), + cs: NewColorScheme(true, false, false, false, LightTheme), input: "this should have dark foreground color and underlined", expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset), }, { name: "when 4-bit color is enabled and theme is dark, 4-bit light color and underline are used", - cs: NewColorScheme(true, false, false, DarkTheme), + cs: NewColorScheme(true, false, false, false, DarkTheme), input: "this should have light foreground color and underlined", expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset), }, { name: "when 8-bit color is enabled but no theme, 4-bit default color and underline are used", - cs: NewColorScheme(true, true, false, NoTheme), + cs: NewColorScheme(true, true, false, false, NoTheme), input: "this should have no explicit color but underlined", expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset), }, { name: "when 8-bit color is enabled and theme is light, 4-bit dark color and underline are used", - cs: NewColorScheme(true, true, false, LightTheme), + cs: NewColorScheme(true, true, false, false, LightTheme), input: "this should have dark foreground color and underlined", expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset), }, { name: "when 8-bit color is true and theme is dark, 4-bit light color and underline are used", - cs: NewColorScheme(true, true, false, DarkTheme), + cs: NewColorScheme(true, true, false, false, DarkTheme), input: "this should have light foreground color and underlined", expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset), }, { name: "when 24-bit color is enabled but no theme, 4-bit default color and underline are used", - cs: NewColorScheme(true, true, true, NoTheme), + cs: NewColorScheme(true, true, true, false, NoTheme), input: "this should have no explicit color but underlined", expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset), }, { name: "when 24-bit color is enabled and theme is light, 4-bit dark color and underline are used", - cs: NewColorScheme(true, true, true, LightTheme), + cs: NewColorScheme(true, true, true, false, LightTheme), input: "this should have dark foreground color and underlined", expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset), }, { name: "when 24-bit color is true and theme is dark, 4-bit light color and underline are used", - cs: NewColorScheme(true, true, true, DarkTheme), + cs: NewColorScheme(true, true, true, false, DarkTheme), input: "this should have light foreground color and underlined", expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset), }, diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 6c12d7911..e78575b9c 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -72,6 +72,7 @@ type IOStreams struct { colorOverride bool colorEnabled bool + colorLabels bool pagerCommand string pagerProcess *os.Process @@ -102,6 +103,10 @@ func (s *IOStreams) HasTrueColor() bool { return s.term.IsTrueColorSupported() } +func (s *IOStreams) ColorLabels() bool { + return s.colorLabels +} + // DetectTerminalTheme is a utility to call before starting the output pager so that the terminal background // can be reliably detected. func (s *IOStreams) DetectTerminalTheme() { @@ -134,6 +139,10 @@ func (s *IOStreams) SetColorEnabled(colorEnabled bool) { s.colorEnabled = colorEnabled } +func (s *IOStreams) SetColorLabels(colorLabels bool) { + s.colorLabels = colorLabels +} + func (s *IOStreams) SetStdinTTY(isTTY bool) { s.stdinTTYOverride = true s.stdinIsTTY = isTTY @@ -366,7 +375,7 @@ func (s *IOStreams) TerminalWidth() int { } func (s *IOStreams) ColorScheme() *ColorScheme { - return NewColorScheme(s.ColorEnabled(), s.ColorSupport256(), s.HasTrueColor(), s.TerminalTheme()) + return NewColorScheme(s.ColorEnabled(), s.ColorSupport256(), s.HasTrueColor(), s.ColorLabels(), s.TerminalTheme()) } func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) { From 5f03c208a1d1dcd862b62d4497fdbfe91f486e77 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Wed, 2 Apr 2025 18:32:37 -0400 Subject: [PATCH 015/147] Fix comment language --- internal/gh/gh.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gh/gh.go b/internal/gh/gh.go index 8f94d7f0d..b17c6bd67 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -37,7 +37,7 @@ type Config interface { // Browser returns the configured browser, optionally scoped by host. Browser(hostname string) ConfigEntry - // ColorLabels returns the configured colorize labels setting, optionally scoped by host. + // ColorLabels returns the configured color_label setting, optionally scoped by host. ColorLabels(hostname string) ConfigEntry // Editor returns the configured editor, optionally scoped by host. Editor(hostname string) ConfigEntry From 8827803bd1d864f2a39076e60052c6dcdea4cb84 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 2 Apr 2025 13:17:53 -0600 Subject: [PATCH 016/147] test(prompter): skip vt10x tests on Windows --- internal/prompter/prompter.go | 26 +- internal/prompter/prompter_test.go | 301 +---------------- ...eech_synthesizer_friendly_prompter_test.go | 302 ++++++++++++++++++ 3 files changed, 319 insertions(+), 310 deletions(-) create mode 100644 internal/prompter/speech_synthesizer_friendly_prompter_test.go diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 93d1d34c0..b9e832bd0 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -41,7 +41,7 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr editorCmd: editorCmd, } default: - return &SpeechSynthesizerFriendlyPrompter{ + return &speechSynthesizerFriendlyPrompter{ stdin: stdin, stdout: stdout, stderr: stderr, @@ -51,7 +51,7 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr } } -type SpeechSynthesizerFriendlyPrompter struct { +type speechSynthesizerFriendlyPrompter struct { stdin ghPrompter.FileReader stdout ghPrompter.FileWriter stderr ghPrompter.FileWriter @@ -60,18 +60,18 @@ type SpeechSynthesizerFriendlyPrompter struct { } // IsAccessible returns true if the huhPrompter was created in accessible mode. -func (p *SpeechSynthesizerFriendlyPrompter) IsAccessible() bool { +func (p *speechSynthesizerFriendlyPrompter) IsAccessible() bool { return p.accessible } -func (p *SpeechSynthesizerFriendlyPrompter) newForm(groups ...*huh.Group) *huh.Form { +func (p *speechSynthesizerFriendlyPrompter) newForm(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). WithTheme(huh.ThemeBase16()). WithAccessible(p.accessible). WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) } -func (p *SpeechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []string) (int, error) { +func (p *speechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []string) (int, error) { var result int formOptions := []huh.Option[int]{} for i, o := range options { @@ -91,7 +91,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []s return result, err } -func (p *SpeechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { +func (p *speechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { var result []int formOptions := make([]huh.Option[int], len(options)) for i, o := range options { @@ -116,7 +116,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults return result[:mid], nil } -func (p *SpeechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) (string, error) { +func (p *speechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) (string, error) { result := defaultValue form := p.newForm( huh.NewGroup( @@ -130,7 +130,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) ( return result, err } -func (p *SpeechSynthesizerFriendlyPrompter) Password(prompt string) (string, error) { +func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -146,7 +146,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) Password(prompt string) (string, err return result, err } -func (p *SpeechSynthesizerFriendlyPrompter) Confirm(prompt string, _ bool) (bool, error) { +func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, _ bool) (bool, error) { var result bool form := p.newForm( huh.NewGroup( @@ -161,7 +161,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) Confirm(prompt string, _ bool) (bool return result, nil } -func (p *SpeechSynthesizerFriendlyPrompter) AuthToken() (string, error) { +func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -183,7 +183,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) AuthToken() (string, error) { return result, err } -func (p *SpeechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { +func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { var result string form := p.newForm( huh.NewGroup( @@ -204,7 +204,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string return form.Run() } -func (p *SpeechSynthesizerFriendlyPrompter) InputHostname() (string, error) { +func (p *speechSynthesizerFriendlyPrompter) InputHostname() (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -219,7 +219,7 @@ func (p *SpeechSynthesizerFriendlyPrompter) InputHostname() (string, error) { return result, err } -func (p *SpeechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { +func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string options := []huh.Option[string]{ huh.NewOption("Open Editor", "open"), diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go index 5c4a45a2c..b83a3e890 100644 --- a/internal/prompter/prompter_test.go +++ b/internal/prompter/prompter_test.go @@ -1,18 +1,9 @@ package prompter import ( - "fmt" - "io" - "os" - "strings" "testing" - "time" - "github.com/Netflix/go-expect" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/creack/pty" - "github.com/hinshun/vt10x" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -28,8 +19,8 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") - require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") + require.IsType(t, &speechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") + require.Equal(t, p.(*speechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") }) t.Run("returns SpeechSynthesizerFriendlyPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { @@ -37,8 +28,8 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { p := New(editorCmd, stdin, stdout, stderr) - require.IsType(t, &SpeechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") - require.Equal(t, p.(*SpeechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") + require.IsType(t, &speechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") + require.Equal(t, p.(*speechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") }) t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { @@ -65,287 +56,3 @@ func TestNewReturnsAccessiblePrompter(t *testing.T) { require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") }) } - -func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { - // Create a PTY and hook up a virtual terminal emulator - ptm, pts, err := pty.Open() - require.NoError(t, err) - - term := vt10x.New(vt10x.WithWriter(pts)) - - // Create a console via Expect that allows scripting against the terminal - consoleOpts := []expect.ConsoleOpt{ - expect.WithStdin(ptm), - expect.WithStdout(term), - expect.WithCloser(ptm, pts), - failOnExpectError(t), - failOnSendError(t), - expect.WithDefaultTimeout(time.Second * 600), - } - - console, err := expect.NewConsole(consoleOpts...) - require.NoError(t, err) - t.Cleanup(func() { testCloser(t, console) }) - - p := &SpeechSynthesizerFriendlyPrompter{ - editorCmd: "", // intentionally empty to cause a failure. - accessible: true, - } - - // Using OS here because huh currently ignores configured iostreams - // See https://github.com/charmbracelet/huh/issues/612 - stdIn := os.Stdin - stdOut := os.Stdout - stdErr := os.Stderr - - t.Cleanup(func() { - os.Stdin = stdIn - os.Stdout = stdOut - os.Stderr = stdErr - }) - - os.Stdin = console.Tty() - os.Stdout = console.Tty() - os.Stderr = console.Tty() - - t.Run("Select", func(t *testing.T) { - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Choose:") - require.NoError(t, err) - - // Select option 1 - _, err = console.SendLine("1") - require.NoError(t, err) - }() - - selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) - require.NoError(t, err) - - assert.Equal(t, 0, selectValue) - }) - - t.Run("MultiSelect", func(t *testing.T) { - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Select a number") - require.NoError(t, err) - - // Select options 1 and 2 - _, err = console.SendLine("1") - require.NoError(t, err) - _, err = console.SendLine("2") - require.NoError(t, err) - - // This confirms selections - _, err = console.SendLine("0") - require.NoError(t, err) - }() - - multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"}) - require.NoError(t, err) - - assert.Equal(t, []int{0, 1}, multiSelectValue) - }) - - t.Run("Input", func(t *testing.T) { - dummyText := "12345abcdefg" - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Enter some characters") - require.NoError(t, err) - - // Enter a number - _, err = console.SendLine(dummyText) - require.NoError(t, err) - }() - - inputValue, err := p.Input("Enter some characters", "") - require.NoError(t, err) - - assert.Equal(t, dummyText, inputValue) - }) - - t.Run("Password", func(t *testing.T) { - dummyPassword := "12345abcdefg" - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Enter password") - require.NoError(t, err) - - // Enter a number - _, err = console.SendLine(dummyPassword) - require.NoError(t, err) - }() - - passwordValue, err := p.Password("Enter password") - require.NoError(t, err) - require.Equal(t, dummyPassword, passwordValue) - }) - - t.Run("Confirm", func(t *testing.T) { - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Are you sure") - require.NoError(t, err) - - // Confirm - _, err = console.SendLine("y") - require.NoError(t, err) - }() - - confirmValue, err := p.Confirm("Are you sure", false) - require.NoError(t, err) - require.Equal(t, true, confirmValue) - }) - - t.Run("AuthToken", func(t *testing.T) { - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Paste your authentication token:") - require.NoError(t, err) - - // Enter a number - _, err = console.SendLine("12345abcdefg") - require.NoError(t, err) - }() - - authValue, err := p.AuthToken() - require.NoError(t, err) - require.Equal(t, "12345abcdefg", authValue) - }) - - t.Run("ConfirmDeletion", func(t *testing.T) { - requiredValue := "test" - go func() { - // Wait for prompt to appear - _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) - require.NoError(t, err) - - // Confirm - _, err = console.SendLine(requiredValue) - require.NoError(t, err) - }() - - // An err indicates that the confirmation text sent did not match - err := p.ConfirmDeletion(requiredValue) - require.NoError(t, err) - }) - - t.Run("InputHostname", func(t *testing.T) { - hostname := "somethingdoesnotmatter.com" - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("Hostname:") - require.NoError(t, err) - - // Enter the hostname - _, err = console.SendLine(hostname) - require.NoError(t, err) - }() - - inputValue, err := p.InputHostname() - require.NoError(t, err) - require.Equal(t, hostname, inputValue) - }) - - t.Run("MarkdownEditor - blank allowed", func(t *testing.T) { - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("How to edit?") - require.NoError(t, err) - - // Enter 2, to select "skip" - _, err = console.SendLine("2") - require.NoError(t, err) - }() - - inputValue, err := p.MarkdownEditor("How to edit?", "", true) - require.NoError(t, err) - require.Equal(t, "", inputValue) - }) - - t.Run("MarkdownEditor - blank disallowed", func(t *testing.T) { - go func() { - // Wait for prompt to appear - _, err := console.ExpectString("How to edit?") - require.NoError(t, err) - - // Enter number 2 to select "skip". This shoudln't be allowed. - _, err = console.SendLine("2") - require.NoError(t, err) - - // Expect a notice to enter something valid since blank is disallowed. - _, err = console.ExpectString("invalid input. please try again") - require.NoError(t, err) - - // Send a 1 to select to open the editor. - // Sending the input won't fail, so we expect no error here. - // See below though, since we expect the editor to fail to open. - _, err = console.SendLine("1") - require.NoError(t, err) - }() - - // However, here we do expect an error because the editor program - // is intentionally empty and will fail. - inputValue, err := p.MarkdownEditor("How to edit?", "", false) - require.Error(t, err) - require.Equal(t, "", inputValue) - }) -} - -// failOnExpectError adds an observer that will fail the test in a standardised way -// if any expectation on the command output fails, without requiring an explicit -// assertion. -// -// Use WithRelaxedIO to disable this behaviour. -func failOnExpectError(t testing.TB) expect.ConsoleOpt { - t.Helper() - return expect.WithExpectObserver( - func(matchers []expect.Matcher, buf string, err error) { - t.Helper() - - if err == nil { - return - } - - if len(matchers) == 0 { - t.Fatalf("Error occurred while matching %q: %s\n", buf, err) - } - - var criteria []string - for _, matcher := range matchers { - criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria())) - } - t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err) - }, - ) -} - -// failOnSendError adds an observer that will fail the test in a standardised way -// if any sending of input fails, without requiring an explicit assertion. -// -// Use WithRelaxedIO to disable this behaviour. -func failOnSendError(t testing.TB) expect.ConsoleOpt { - t.Helper() - return expect.WithSendObserver( - func(msg string, n int, err error) { - t.Helper() - - if err != nil { - t.Fatalf("Failed to send %q: %s\n", msg, err) - } - if len(msg) != n { - t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg) - } - }, - ) -} - -// testCloser is a helper to fail the test if a Closer fails to close. -func testCloser(t testing.TB, closer io.Closer) { - t.Helper() - if err := closer.Close(); err != nil { - t.Errorf("Close failed: %s", err) - } -} diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go new file mode 100644 index 000000000..155258719 --- /dev/null +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -0,0 +1,302 @@ +//go:build !windows + +package prompter + +import ( + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/Netflix/go-expect" + "github.com/creack/pty" + "github.com/hinshun/vt10x" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { + // Create a PTY and hook up a virtual terminal emulator + ptm, pts, err := pty.Open() + require.NoError(t, err) + + term := vt10x.New(vt10x.WithWriter(pts)) + + // Create a console via Expect that allows scripting against the terminal + consoleOpts := []expect.ConsoleOpt{ + expect.WithStdin(ptm), + expect.WithStdout(term), + expect.WithCloser(ptm, pts), + failOnExpectError(t), + failOnSendError(t), + expect.WithDefaultTimeout(time.Second * 600), + } + + console, err := expect.NewConsole(consoleOpts...) + require.NoError(t, err) + t.Cleanup(func() { testCloser(t, console) }) + + p := &speechSynthesizerFriendlyPrompter{ + editorCmd: "", // intentionally empty to cause a failure. + accessible: true, + } + + // Using OS here because huh currently ignores configured iostreams + // See https://github.com/charmbracelet/huh/issues/612 + stdIn := os.Stdin + stdOut := os.Stdout + stdErr := os.Stderr + + t.Cleanup(func() { + os.Stdin = stdIn + os.Stdout = stdOut + os.Stderr = stdErr + }) + + os.Stdin = console.Tty() + os.Stdout = console.Tty() + os.Stderr = console.Tty() + + t.Run("Select", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Choose:") + require.NoError(t, err) + + // Select option 1 + _, err = console.SendLine("1") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) + require.NoError(t, err) + + assert.Equal(t, 0, selectValue) + }) + + t.Run("MultiSelect", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select a number") + require.NoError(t, err) + + // Select options 1 and 2 + _, err = console.SendLine("1") + require.NoError(t, err) + _, err = console.SendLine("2") + require.NoError(t, err) + + // This confirms selections + _, err = console.SendLine("0") + require.NoError(t, err) + }() + + multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"}) + require.NoError(t, err) + + assert.Equal(t, []int{0, 1}, multiSelectValue) + }) + + t.Run("Input", func(t *testing.T) { + dummyText := "12345abcdefg" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter some characters") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine(dummyText) + require.NoError(t, err) + }() + + inputValue, err := p.Input("Enter some characters", "") + require.NoError(t, err) + + assert.Equal(t, dummyText, inputValue) + }) + + t.Run("Password", func(t *testing.T) { + dummyPassword := "12345abcdefg" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter password") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine(dummyPassword) + require.NoError(t, err) + }() + + passwordValue, err := p.Password("Enter password") + require.NoError(t, err) + require.Equal(t, dummyPassword, passwordValue) + }) + + t.Run("Confirm", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Are you sure") + require.NoError(t, err) + + // Confirm + _, err = console.SendLine("y") + require.NoError(t, err) + }() + + confirmValue, err := p.Confirm("Are you sure", false) + require.NoError(t, err) + require.Equal(t, true, confirmValue) + }) + + t.Run("AuthToken", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Paste your authentication token:") + require.NoError(t, err) + + // Enter a number + _, err = console.SendLine("12345abcdefg") + require.NoError(t, err) + }() + + authValue, err := p.AuthToken() + require.NoError(t, err) + require.Equal(t, "12345abcdefg", authValue) + }) + + t.Run("ConfirmDeletion", func(t *testing.T) { + requiredValue := "test" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) + require.NoError(t, err) + + // Confirm + _, err = console.SendLine(requiredValue) + require.NoError(t, err) + }() + + // An err indicates that the confirmation text sent did not match + err := p.ConfirmDeletion(requiredValue) + require.NoError(t, err) + }) + + t.Run("InputHostname", func(t *testing.T) { + hostname := "somethingdoesnotmatter.com" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Hostname:") + require.NoError(t, err) + + // Enter the hostname + _, err = console.SendLine(hostname) + require.NoError(t, err) + }() + + inputValue, err := p.InputHostname() + require.NoError(t, err) + require.Equal(t, hostname, inputValue) + }) + + t.Run("MarkdownEditor - blank allowed", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("How to edit?") + require.NoError(t, err) + + // Enter 2, to select "skip" + _, err = console.SendLine("2") + require.NoError(t, err) + }() + + inputValue, err := p.MarkdownEditor("How to edit?", "", true) + require.NoError(t, err) + require.Equal(t, "", inputValue) + }) + + t.Run("MarkdownEditor - blank disallowed", func(t *testing.T) { + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("How to edit?") + require.NoError(t, err) + + // Enter number 2 to select "skip". This shoudln't be allowed. + _, err = console.SendLine("2") + require.NoError(t, err) + + // Expect a notice to enter something valid since blank is disallowed. + _, err = console.ExpectString("invalid input. please try again") + require.NoError(t, err) + + // Send a 1 to select to open the editor. + // Sending the input won't fail, so we expect no error here. + // See below though, since we expect the editor to fail to open. + _, err = console.SendLine("1") + require.NoError(t, err) + }() + + // However, here we do expect an error because the editor program + // is intentionally empty and will fail. + inputValue, err := p.MarkdownEditor("How to edit?", "", false) + require.Error(t, err) + require.Equal(t, "", inputValue) + }) +} + +// failOnExpectError adds an observer that will fail the test in a standardised way +// if any expectation on the command output fails, without requiring an explicit +// assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnExpectError(t testing.TB) expect.ConsoleOpt { + t.Helper() + return expect.WithExpectObserver( + func(matchers []expect.Matcher, buf string, err error) { + t.Helper() + + if err == nil { + return + } + + if len(matchers) == 0 { + t.Fatalf("Error occurred while matching %q: %s\n", buf, err) + } + + var criteria []string + for _, matcher := range matchers { + criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria())) + } + t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err) + }, + ) +} + +// failOnSendError adds an observer that will fail the test in a standardised way +// if any sending of input fails, without requiring an explicit assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnSendError(t testing.TB) expect.ConsoleOpt { + t.Helper() + return expect.WithSendObserver( + func(msg string, n int, err error) { + t.Helper() + + if err != nil { + t.Fatalf("Failed to send %q: %s\n", msg, err) + } + if len(msg) != n { + t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg) + } + }, + ) +} + +// testCloser is a helper to fail the test if a Closer fails to close. +func testCloser(t testing.TB, closer io.Closer) { + t.Helper() + if err := closer.Close(); err != nil { + t.Errorf("Close failed: %s", err) + } +} From 88e6285b49d5a476e3e90c7d6867a91d346cb94a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 3 Apr 2025 08:45:58 -0600 Subject: [PATCH 017/147] test(prompter): move to external package --- internal/prompter/prompter.go | 29 ++++------ internal/prompter/prompter_test.go | 58 ------------------- ...eech_synthesizer_friendly_prompter_test.go | 16 ++--- 3 files changed, 19 insertions(+), 84 deletions(-) delete mode 100644 internal/prompter/prompter_test.go diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index b9e832bd0..e764f7138 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/AlecAivazis/survey/v2" - tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/surveyext" @@ -42,33 +41,27 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr } default: return &speechSynthesizerFriendlyPrompter{ - stdin: stdin, - stdout: stdout, - stderr: stderr, - editorCmd: editorCmd, - accessible: true, + stdin: stdin, + stdout: stdout, + stderr: stderr, + editorCmd: editorCmd, } } } type speechSynthesizerFriendlyPrompter struct { - stdin ghPrompter.FileReader - stdout ghPrompter.FileWriter - stderr ghPrompter.FileWriter - editorCmd string - accessible bool -} - -// IsAccessible returns true if the huhPrompter was created in accessible mode. -func (p *speechSynthesizerFriendlyPrompter) IsAccessible() bool { - return p.accessible + stdin ghPrompter.FileReader + stdout ghPrompter.FileWriter + stderr ghPrompter.FileWriter + editorCmd string } func (p *speechSynthesizerFriendlyPrompter) newForm(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). WithTheme(huh.ThemeBase16()). - WithAccessible(p.accessible). - WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) + WithAccessible(true) + // Commented out because https://github.com/charmbracelet/huh/issues/612 + // WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) } func (p *speechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []string) (int, error) { diff --git a/internal/prompter/prompter_test.go b/internal/prompter/prompter_test.go deleted file mode 100644 index b83a3e890..000000000 --- a/internal/prompter/prompter_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package prompter - -import ( - "testing" - - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/stretchr/testify/require" -) - -func TestNewReturnsAccessiblePrompter(t *testing.T) { - editorCmd := "nothing" - ios, _, _, _ := iostreams.Test() - stdin := ios.In - stdout := ios.Out - stderr := ios.ErrOut - - t.Run("returns SpeechSynthesizerFriendlyPrompter when GH_SCREENREADER_FRIENDLY is set to true", func(t *testing.T) { - t.Setenv("GH_SCREENREADER_FRIENDLY", "true") - - p := New(editorCmd, stdin, stdout, stderr) - - require.IsType(t, &speechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") - require.Equal(t, p.(*speechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") - }) - - t.Run("returns SpeechSynthesizerFriendlyPrompter when GH_SCREENREADER_FRIENDLY is set to 1", func(t *testing.T) { - t.Setenv("GH_SCREENREADER_FRIENDLY", "1") - - p := New(editorCmd, stdin, stdout, stderr) - - require.IsType(t, &speechSynthesizerFriendlyPrompter{}, p, "expected SpeechSynthesizerFriendlyPrompter to be returned") - require.Equal(t, p.(*speechSynthesizerFriendlyPrompter).IsAccessible(), true, "expected SpeechSynthesizerFriendlyPrompter to be accessible") - }) - - t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to false", func(t *testing.T) { - t.Setenv("GH_SCREENREADER_FRIENDLY", "false") - - p := New(editorCmd, stdin, stdout, stderr) - - require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") - }) - - t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is set to 0", func(t *testing.T) { - t.Setenv("GH_SCREENREADER_FRIENDLY", "0") - - p := New(editorCmd, stdin, stdout, stderr) - - require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") - }) - - t.Run("returns surveyPrompter when GH_SCREENREADER_FRIENDLY is unset", func(t *testing.T) { - t.Setenv("GH_SCREENREADER_FRIENDLY", "") - - p := New(editorCmd, stdin, stdout, stderr) - - require.IsType(t, &surveyPrompter{}, p, "expected surveyPrompter to be returned") - }) -} diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 155258719..2ccea38a4 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -1,6 +1,6 @@ //go:build !windows -package prompter +package prompter_test import ( "fmt" @@ -11,6 +11,7 @@ import ( "time" "github.com/Netflix/go-expect" + "github.com/cli/cli/v2/internal/prompter" "github.com/creack/pty" "github.com/hinshun/vt10x" "github.com/stretchr/testify/assert" @@ -38,13 +39,6 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { testCloser(t, console) }) - p := &speechSynthesizerFriendlyPrompter{ - editorCmd: "", // intentionally empty to cause a failure. - accessible: true, - } - - // Using OS here because huh currently ignores configured iostreams - // See https://github.com/charmbracelet/huh/issues/612 stdIn := os.Stdin stdOut := os.Stdout stdErr := os.Stderr @@ -59,6 +53,11 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { os.Stdout = console.Tty() os.Stderr = console.Tty() + // Using OS here because huh currently ignores configured iostreams + // See https://github.com/charmbracelet/huh/issues/612 + t.Setenv("GH_SCREENREADER_FRIENDLY", "true") + p := prompter.New("", nil, nil, nil) + t.Run("Select", func(t *testing.T) { go func() { // Wait for prompt to appear @@ -150,6 +149,7 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { require.Equal(t, true, confirmValue) }) + // Need one that enters invalid input t.Run("AuthToken", func(t *testing.T) { go func() { // Wait for prompt to appear From 02fc12e7b74d3aab2060bdfd415f53b2b733dc7a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 3 Apr 2025 08:51:54 -0600 Subject: [PATCH 018/147] fix(linter): linter errors --- go.mod | 2 +- internal/prompter/speech_synthesizer_friendly_prompter_test.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 85b0f88d4..9e2a2915b 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,6 @@ require ( github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 github.com/briandowns/spinner v1.18.1 github.com/cenkalti/backoff/v4 v4.3.0 - github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc @@ -74,6 +73,7 @@ require ( github.com/blang/semver v3.5.1+incompatible // indirect github.com/catppuccin/go v0.2.0 // indirect github.com/charmbracelet/bubbles v0.20.0 // indirect + github.com/charmbracelet/bubbletea v1.3.4 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 2ccea38a4..b8d4571f6 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -149,7 +149,8 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { require.Equal(t, true, confirmValue) }) - // Need one that enters invalid input + // TODO: Need one that enters invalid input + // TODO: write tests for control-c t.Run("AuthToken", func(t *testing.T) { go func() { // Wait for prompt to appear From 49ddacf5b85cff6d72faded445047a9e4485f298 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 3 Apr 2025 13:06:31 -0600 Subject: [PATCH 019/147] docs(prompter): doc prompter interface --- internal/prompter/prompter.go | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index e764f7138..317e9094a 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -15,17 +15,31 @@ import ( //go:generate moq -rm -out prompter_mock.go . Prompter type Prompter interface { // generic prompts from go-gh - Select(string, string, []string) (int, error) + + // Select prompts the user to select an option from a list of options. + Select(prompt string, defaultValue string, options []string) (int, error) + // MultiSelect prompts the user to select one or more options from a list of options. MultiSelect(prompt string, defaults []string, options []string) ([]int, error) - Input(string, string) (string, error) - Password(string) (string, error) - Confirm(string, bool) (bool, error) + // Input prompts the user to enter a string value. + Input(prompt string, defaultValue string) (string, error) + // Password prompts the user to enter a password. + Password(prompt string) (string, error) + // Confirm prompts the user to confirm an action. + Confirm(prompt string, defaultValue bool) (bool, error) // gh specific prompts + + // AuthToken prompts the user to enter an authentication token. AuthToken() (string, error) - ConfirmDeletion(string) error + // ConfirmDeletion prompts the user to confirm deletion of a resource by + // typing the requiredValue. + ConfirmDeletion(requiredValue string) error + // InputHostname prompts the user to enter a hostname. InputHostname() (string, error) - MarkdownEditor(string, string, bool) (string, error) + // MarkdownEditor prompts the user to edit a markdown document in an editor. + // If blankAllowed is true, the user can skip the editor and an empty string + // will be returned. + MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error) } func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter { From a30df14b6abac659f2633b50b03e3b1de665598b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 3 Apr 2025 13:08:16 -0600 Subject: [PATCH 020/147] refactor(prompter): rename env var for speech synthesizer friendly prompter --- internal/prompter/prompter.go | 2 +- .../prompter/speech_synthesizer_friendly_prompter_test.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 317e9094a..86408c645 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -43,7 +43,7 @@ type Prompter interface { } func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter { - accessiblePrompterValue := os.Getenv("GH_SCREENREADER_FRIENDLY") + accessiblePrompterValue := os.Getenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER") switch accessiblePrompterValue { case "", "false", "0", "no": return &surveyPrompter{ diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index b8d4571f6..21d126f75 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -39,6 +39,8 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { testCloser(t, console) }) + // Using OS here because huh currently ignores configured iostreams + // See https://github.com/charmbracelet/huh/issues/612 stdIn := os.Stdin stdOut := os.Stdout stdErr := os.Stderr @@ -53,9 +55,7 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { os.Stdout = console.Tty() os.Stderr = console.Tty() - // Using OS here because huh currently ignores configured iostreams - // See https://github.com/charmbracelet/huh/issues/612 - t.Setenv("GH_SCREENREADER_FRIENDLY", "true") + t.Setenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER", "true") p := prompter.New("", nil, nil, nil) t.Run("Select", func(t *testing.T) { From addbc6ac5c0dc3ed4611904972d5b122e4426756 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Fri, 4 Apr 2025 11:02:44 -0400 Subject: [PATCH 021/147] Add label color env var to help topic, unused fix This commit adds the new environment variable to the `gh environment` help topic. Additionally, there is a small fix for Go linter for an unused variable raised as a problem. --- pkg/cmd/root/help.go | 2 +- pkg/cmd/root/help_topic.go | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go index a7daa7b84..7f8fb1c2e 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -87,7 +87,7 @@ func isRootCmd(command *cobra.Command) bool { return command != nil && !command.HasParent() } -func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) { +func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) { flags := command.Flags() if isRootCmd(command) { diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index db0ef098d..51fb0662a 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -81,6 +81,9 @@ var HelpTopics = []helpTopic{ %[1]sCLICOLOR_FORCE%[1]s: set to a value other than %[1]s0%[1]s to keep ANSI colors in output even when the output is piped. + %[1]sGH_COLOR_LABELS%[1]s: set to any value to display labels using their RGB hex color codes in terminals that + support truecolor. + %[1]sGH_FORCE_TTY%[1]s: set to any value to force terminal-style output even when the output is redirected. When the value is a number, it is interpreted as the number of columns available in the viewport. When the value is a percentage, it will be applied against From e067eacd8114e0880c79e256eb11a119b647a9de Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Fri, 4 Apr 2025 11:57:37 -0400 Subject: [PATCH 022/147] Refactor ColorScheme initializer This commit completely removes the iostreams.NewColorScheme() initializer function in favor of exporting the type fields for greater clarity in its use. The result being code specifying only the fields that matter to test cases. --- internal/tableprinter/table_printer.go | 2 +- pkg/cmd/gist/list/list_test.go | 27 ++-- pkg/iostreams/color.go | 77 +++++------- pkg/iostreams/color_test.go | 168 +++++++++++++++++++------ pkg/iostreams/iostreams.go | 9 +- 5 files changed, 189 insertions(+), 94 deletions(-) diff --git a/internal/tableprinter/table_printer.go b/internal/tableprinter/table_printer.go index 69b22be12..47128afb4 100644 --- a/internal/tableprinter/table_printer.go +++ b/internal/tableprinter/table_printer.go @@ -73,7 +73,7 @@ func NewWithWriter(w io.Writer, isTTY bool, maxWidth int, cs *iostreams.ColorSch // was not padded. In tests cs.Enabled() is false which allows us to avoid having to fix up // numerous tests that verify header padding. var paddingFunc func(int, string) string - if cs.Enabled() { + if cs.Enabled { paddingFunc = text.PadRight } diff --git a/pkg/cmd/gist/list/list_test.go b/pkg/cmd/gist/list/list_test.go index 8fbf4d6c9..14351418f 100644 --- a/pkg/cmd/gist/list/list_test.go +++ b/pkg/cmd/gist/list/list_test.go @@ -654,50 +654,57 @@ func Test_highlightMatch(t *testing.T) { tests := []struct { name string input string - color bool + cs *iostreams.ColorScheme want string }{ { name: "single match", input: "Octo", + cs: &iostreams.ColorScheme{}, want: "Octo", }, { name: "single match (color)", input: "Octo", - color: true, - want: "\x1b[0;30;43mOcto\x1b[0m", + cs: &iostreams.ColorScheme{ + Enabled: true, + }, + want: "\x1b[0;30;43mOcto\x1b[0m", }, { name: "single match with extra", input: "Hello, Octocat!", + cs: &iostreams.ColorScheme{}, want: "Hello, Octocat!", }, { name: "single match with extra (color)", input: "Hello, Octocat!", - color: true, - want: "\x1b[0;34mHello, \x1b[0m\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat!\x1b[0m", + cs: &iostreams.ColorScheme{ + Enabled: true, + }, + want: "\x1b[0;34mHello, \x1b[0m\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat!\x1b[0m", }, { name: "multiple matches", input: "Octocat/octo", + cs: &iostreams.ColorScheme{}, want: "Octocat/octo", }, { name: "multiple matches (color)", input: "Octocat/octo", - color: true, - want: "\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat/\x1b[0m\x1b[0;30;43mocto\x1b[0m", + cs: &iostreams.ColorScheme{ + Enabled: true, + }, + want: "\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat/\x1b[0m\x1b[0;30;43mocto\x1b[0m", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cs := iostreams.NewColorScheme(tt.color, false, false, false, false, iostreams.NoTheme) - matched := false - got, err := highlightMatch(tt.input, regex, &matched, cs.Blue, cs.Highlight) + got, err := highlightMatch(tt.input, regex, &matched, tt.cs.Blue, tt.cs.Highlight) assert.NoError(t, err) assert.True(t, matched) assert.Equal(t, tt.want, got) diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index 92b94c360..86512df4e 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -41,35 +41,24 @@ var ( } ) -// NewColorScheme initializes color logic based on provided terminal capabilities. -// Logic dealing with terminal theme detected, such as whether color is enabled, 8-bit color supported, true color supported, -// labels are colored, and terminal theme detected. -func NewColorScheme(enabled, is256enabled, trueColor, accessibleColors, colorLabels bool, theme string) *ColorScheme { - return &ColorScheme{ - enabled: enabled, - is256enabled: is256enabled, - hasTrueColor: trueColor, - accessibleColors: accessibleColors, - colorLabels: colorLabels, - theme: theme, - } -} - +// ColorScheme controls how text is colored based upon terminal capabilities and user preferences. type ColorScheme struct { - enabled bool - is256enabled bool - hasTrueColor bool - accessibleColors bool - colorLabels bool - theme string -} - -func (c *ColorScheme) Enabled() bool { - return c.enabled + // Enabled is whether color is used at all. + Enabled bool + // EightBitColor is whether the terminal supports 8-bit, 256 colors. + EightBitColor bool + // TrueColor is whether the terminal supports 24-bit, 16 million colors. + TrueColor bool + // Accessible is whether colors must be base 16 colors that users can customize in terminal preferences. + Accessible bool + // ColorLabels is whether labels are colored based on their truecolor RGB hex color. + ColorLabels bool + // Theme is the terminal background color theme used to contextually color text for light, dark, or none at all. + Theme string } func (c *ColorScheme) Bold(t string) string { - if !c.enabled { + if !c.Enabled { return t } return bold(t) @@ -81,16 +70,16 @@ func (c *ColorScheme) Boldf(t string, args ...interface{}) string { func (c *ColorScheme) Muted(t string) string { // Fallback to previous logic if accessible colors preview is disabled. - if !c.accessibleColors { + if !c.Accessible { return c.Gray(t) } // Muted text is only stylized if color is enabled. - if !c.enabled { + if !c.Enabled { return t } - switch c.theme { + switch c.Theme { case LightTheme: return lightThemeMuted(t) case DarkTheme: @@ -105,7 +94,7 @@ func (c *ColorScheme) Mutedf(t string, args ...interface{}) string { } func (c *ColorScheme) Red(t string) string { - if !c.enabled { + if !c.Enabled { return t } return red(t) @@ -116,7 +105,7 @@ func (c *ColorScheme) Redf(t string, args ...interface{}) string { } func (c *ColorScheme) Yellow(t string) string { - if !c.enabled { + if !c.Enabled { return t } return yellow(t) @@ -127,7 +116,7 @@ func (c *ColorScheme) Yellowf(t string, args ...interface{}) string { } func (c *ColorScheme) Green(t string) string { - if !c.enabled { + if !c.Enabled { return t } return green(t) @@ -138,7 +127,7 @@ func (c *ColorScheme) Greenf(t string, args ...interface{}) string { } func (c *ColorScheme) GreenBold(t string) string { - if !c.enabled { + if !c.Enabled { return t } return greenBold(t) @@ -146,10 +135,10 @@ func (c *ColorScheme) GreenBold(t string) string { // Use Muted instead for thematically contrasting color. func (c *ColorScheme) Gray(t string) string { - if !c.enabled { + if !c.Enabled { return t } - if c.is256enabled { + if c.EightBitColor { return gray256(t) } return gray(t) @@ -161,7 +150,7 @@ func (c *ColorScheme) Grayf(t string, args ...interface{}) string { } func (c *ColorScheme) Magenta(t string) string { - if !c.enabled { + if !c.Enabled { return t } return magenta(t) @@ -172,7 +161,7 @@ func (c *ColorScheme) Magentaf(t string, args ...interface{}) string { } func (c *ColorScheme) Cyan(t string) string { - if !c.enabled { + if !c.Enabled { return t } return cyan(t) @@ -183,14 +172,14 @@ func (c *ColorScheme) Cyanf(t string, args ...interface{}) string { } func (c *ColorScheme) CyanBold(t string) string { - if !c.enabled { + if !c.Enabled { return t } return cyanBold(t) } func (c *ColorScheme) Blue(t string) string { - if !c.enabled { + if !c.Enabled { return t } return blue(t) @@ -221,7 +210,7 @@ func (c *ColorScheme) FailureIconWithColor(colo func(string) string) string { } func (c *ColorScheme) HighlightStart() string { - if !c.enabled { + if !c.Enabled { return "" } @@ -229,7 +218,7 @@ func (c *ColorScheme) HighlightStart() string { } func (c *ColorScheme) Highlight(t string) string { - if !c.enabled { + if !c.Enabled { return t } @@ -237,7 +226,7 @@ func (c *ColorScheme) Highlight(t string) string { } func (c *ColorScheme) Reset() string { - if !c.enabled { + if !c.Enabled { return "" } @@ -275,7 +264,7 @@ func (c *ColorScheme) ColorFromString(s string) func(string) string { // Label stylizes text based on label's RGB hex color. func (c *ColorScheme) Label(hex string, x string) string { - if !c.enabled || !c.hasTrueColor || !c.colorLabels || len(hex) != 6 { + if !c.Enabled || !c.TrueColor || !c.ColorLabels || len(hex) != 6 { return x } @@ -287,11 +276,11 @@ func (c *ColorScheme) Label(hex string, x string) string { func (c *ColorScheme) TableHeader(t string) string { // Table headers are only stylized if color is enabled including underline modifier. - if !c.enabled { + if !c.Enabled { return t } - switch c.theme { + switch c.Theme { case DarkTheme: return darkThemeTableHeader(t) case LightTheme: diff --git a/pkg/iostreams/color_test.go b/pkg/iostreams/color_test.go index 2adacd63f..f6a72e2a7 100644 --- a/pkg/iostreams/color_test.go +++ b/pkg/iostreams/color_test.go @@ -20,35 +20,52 @@ func TestLabel(t *testing.T) { hex: "fc0303", text: "red", wants: "\033[38;2;252;3;3mred\033[0m", - cs: NewColorScheme(true, true, true, false, true, NoTheme), + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + ColorLabels: true, + }, }, { name: "no truecolor", hex: "fc0303", text: "red", wants: "red", - cs: NewColorScheme(true, true, false, false, true, NoTheme), + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + ColorLabels: true, + }, }, { name: "no color", hex: "fc0303", text: "red", wants: "red", - cs: NewColorScheme(false, false, false, false, true, NoTheme), + cs: &ColorScheme{ + ColorLabels: true, + }, }, { name: "invalid hex", hex: "fc0", text: "red", wants: "red", - cs: NewColorScheme(false, false, false, false, true, NoTheme), + cs: &ColorScheme{ + ColorLabels: true, + }, }, { name: "no color labels", hex: "fc0303", text: "red", wants: "red", - cs: NewColorScheme(true, true, true, false, false, NoTheme), + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + ColorLabels: true, + }, }, } @@ -71,62 +88,110 @@ func TestTableHeader(t *testing.T) { expected string }{ { - name: "when color is disabled, text is not stylized", - cs: NewColorScheme(false, false, false, true, false, NoTheme), + name: "when color is disabled, text is not stylized", + cs: &ColorScheme{ + Accessible: true, + Theme: NoTheme, + }, input: "this should not be stylized", expected: "this should not be stylized", }, { - name: "when 4-bit color is enabled but no theme, 4-bit default color and underline are used", - cs: NewColorScheme(true, false, false, true, false, NoTheme), + name: "when 4-bit color is enabled but no theme, 4-bit default color and underline are used", + cs: &ColorScheme{ + Enabled: true, + Accessible: true, + Theme: NoTheme, + }, input: "this should have no explicit color but underlined", expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset), }, { - name: "when 4-bit color is enabled and theme is light, 4-bit dark color and underline are used", - cs: NewColorScheme(true, false, false, true, false, LightTheme), + name: "when 4-bit color is enabled and theme is light, 4-bit dark color and underline are used", + cs: &ColorScheme{ + Enabled: true, + Accessible: true, + Theme: LightTheme, + }, input: "this should have dark foreground color and underlined", expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset), }, { - name: "when 4-bit color is enabled and theme is dark, 4-bit light color and underline are used", - cs: NewColorScheme(true, false, false, true, false, DarkTheme), + name: "when 4-bit color is enabled and theme is dark, 4-bit light color and underline are used", + cs: &ColorScheme{ + Enabled: true, + Accessible: true, + Theme: DarkTheme, + }, input: "this should have light foreground color and underlined", expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset), }, { - name: "when 8-bit color is enabled but no theme, 4-bit default color and underline are used", - cs: NewColorScheme(true, true, false, true, false, NoTheme), + name: "when 8-bit color is enabled but no theme, 4-bit default color and underline are used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + Accessible: true, + Theme: NoTheme, + }, input: "this should have no explicit color but underlined", expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset), }, { - name: "when 8-bit color is enabled and theme is light, 4-bit dark color and underline are used", - cs: NewColorScheme(true, true, false, true, false, LightTheme), + name: "when 8-bit color is enabled and theme is light, 4-bit dark color and underline are used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + Accessible: true, + Theme: LightTheme, + }, input: "this should have dark foreground color and underlined", expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset), }, { - name: "when 8-bit color is true and theme is dark, 4-bit light color and underline are used", - cs: NewColorScheme(true, true, false, true, false, DarkTheme), + name: "when 8-bit color is true and theme is dark, 4-bit light color and underline are used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + Accessible: true, + Theme: DarkTheme, + }, input: "this should have light foreground color and underlined", expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset), }, { - name: "when 24-bit color is enabled but no theme, 4-bit default color and underline are used", - cs: NewColorScheme(true, true, true, true, false, NoTheme), + name: "when 24-bit color is enabled but no theme, 4-bit default color and underline are used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + Accessible: true, + Theme: NoTheme, + }, input: "this should have no explicit color but underlined", expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset), }, { - name: "when 24-bit color is enabled and theme is light, 4-bit dark color and underline are used", - cs: NewColorScheme(true, true, true, true, false, LightTheme), + name: "when 24-bit color is enabled and theme is light, 4-bit dark color and underline are used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + Accessible: true, + Theme: LightTheme, + }, input: "this should have dark foreground color and underlined", expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset), }, { - name: "when 24-bit color is true and theme is dark, 4-bit light color and underline are used", - cs: NewColorScheme(true, true, true, true, false, DarkTheme), + name: "when 24-bit color is true and theme is dark, 4-bit light color and underline are used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + Accessible: true, + Theme: DarkTheme, + }, input: "this should have light foreground color and underlined", expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset), }, @@ -154,43 +219,70 @@ func TestMuted(t *testing.T) { }{ { name: "when color is disabled but accessible colors are disabled, text is not stylized", - cs: NewColorScheme(false, false, false, false, false, NoTheme), + cs: &ColorScheme{}, input: "this should not be stylized", expected: "this should not be stylized", }, { - name: "when 4-bit color is enabled but accessible colors are disabled, legacy 4-bit gray color is used", - cs: NewColorScheme(true, false, false, false, false, NoTheme), + name: "when 4-bit color is enabled but accessible colors are disabled, legacy 4-bit gray color is used", + cs: &ColorScheme{ + Enabled: true, + }, input: "this should be 4-bit gray", expected: fmt.Sprintf("%sthis should be 4-bit gray%s", gray4bit, reset), }, { - name: "when 8-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used", - cs: NewColorScheme(true, true, false, false, false, NoTheme), + name: "when 8-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + }, input: "this should be 8-bit gray", expected: fmt.Sprintf("%sthis should be 8-bit gray%s", gray8bit, reset), }, { - name: "when 24-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used", - cs: NewColorScheme(true, true, true, false, false, NoTheme), + name: "when 24-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + }, input: "this should be 8-bit gray", expected: fmt.Sprintf("%sthis should be 8-bit gray%s", gray8bit, reset), }, { - name: "when 4-bit color is enabled and theme is dark, 4-bit light color is used", - cs: NewColorScheme(true, true, true, true, false, DarkTheme), + name: "when 4-bit color is enabled and theme is dark, 4-bit light color is used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + Accessible: true, + Theme: DarkTheme, + }, input: "this should be 4-bit dim black", expected: fmt.Sprintf("%sthis should be 4-bit dim black%s", dimBlack4bit, reset), }, { - name: "when 4-bit color is enabled and theme is light, 4-bit dark color is used", - cs: NewColorScheme(true, true, true, true, false, LightTheme), + name: "when 4-bit color is enabled and theme is light, 4-bit dark color is used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + Accessible: true, + Theme: LightTheme, + }, input: "this should be 4-bit bright black", expected: fmt.Sprintf("%sthis should be 4-bit bright black%s", brightBlack4bit, reset), }, { - name: "when 4-bit color is enabled but no theme, 4-bit default color is used", - cs: NewColorScheme(true, true, true, true, false, NoTheme), + name: "when 4-bit color is enabled but no theme, 4-bit default color is used", + cs: &ColorScheme{ + Enabled: true, + EightBitColor: true, + TrueColor: true, + Accessible: true, + Theme: NoTheme, + }, input: "this should have no explicit color", expected: "this should have no explicit color", }, diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 28e81abba..f5e3c2aee 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -376,7 +376,14 @@ func (s *IOStreams) TerminalWidth() int { } func (s *IOStreams) ColorScheme() *ColorScheme { - return NewColorScheme(s.ColorEnabled(), s.ColorSupport256(), s.HasTrueColor(), s.AccessibleColorsEnabled(), s.ColorLabels(), s.TerminalTheme()) + return &ColorScheme{ + Enabled: s.ColorEnabled(), + EightBitColor: s.ColorSupport256(), + TrueColor: s.HasTrueColor(), + Accessible: s.AccessibleColorsEnabled(), + ColorLabels: s.ColorLabels(), + Theme: s.TerminalTheme(), + } } func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) { From 5b0d49c6ecf9ef704a3f36d6701e81412b0e54f7 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 4 Apr 2025 11:06:40 -0600 Subject: [PATCH 023/147] test(prompter): more tests for bad input --- internal/prompter/prompter.go | 3 +- ...eech_synthesizer_friendly_prompter_test.go | 58 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 86408c645..f9146d8b7 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -174,6 +174,7 @@ func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { huh.NewGroup( huh.NewInput(). Title("Paste your authentication token:"). + // Note: if this validation fails, the prompt loops. Validate(func(input string) error { if input == "" { return fmt.Errorf("token is required") @@ -229,7 +230,7 @@ func (p *speechSynthesizerFriendlyPrompter) InputHostname() (string, error) { func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string options := []huh.Option[string]{ - huh.NewOption("Open Editor", "open"), + huh.NewOption(fmt.Sprintf("Open Editor: %s", p.editorCmd), "open"), } if blankAllowed { options = append(options, huh.NewOption("Skip", "skip")) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 21d126f75..119106f64 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -152,19 +152,45 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { // TODO: Need one that enters invalid input // TODO: write tests for control-c t.Run("AuthToken", func(t *testing.T) { + dummyAuthToken := "12345abcdefg" go func() { // Wait for prompt to appear _, err := console.ExpectString("Paste your authentication token:") require.NoError(t, err) - // Enter a number - _, err = console.SendLine("12345abcdefg") + // Enter some dummy auth token + _, err = console.SendLine(dummyAuthToken) require.NoError(t, err) }() authValue, err := p.AuthToken() require.NoError(t, err) - require.Equal(t, "12345abcdefg", authValue) + require.Equal(t, dummyAuthToken, authValue) + }) + + t.Run("AuthToken - blank input returns error", func(t *testing.T) { + dummyAuthTokenForAfterFailure := "12345abcdefg" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Paste your authentication token:") + require.NoError(t, err) + + // Enter nothing + _, err = console.SendLine("") + require.NoError(t, err) + + // Expect an error message + _, err = console.ExpectString("token is required") + require.NoError(t, err) + + // Now enter some dummy auth token to return control back to the test + _, err = console.SendLine(dummyAuthTokenForAfterFailure) + require.NoError(t, err) + }() + + authValue, err := p.AuthToken() + require.NoError(t, err) + require.Equal(t, dummyAuthTokenForAfterFailure, authValue) }) t.Run("ConfirmDeletion", func(t *testing.T) { @@ -184,6 +210,32 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { require.NoError(t, err) }) + t.Run("ConfirmDeletion - bad input", func(t *testing.T) { + requiredValue := "test" + badInputValue := "garbage" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) + require.NoError(t, err) + + // Confirm with bad input + _, err = console.SendLine(badInputValue) + require.NoError(t, err) + + // Expect an error message and loop back to the prompt + _, err = console.ExpectString(fmt.Sprintf("You entered: %q", badInputValue)) + require.NoError(t, err) + + // Confirm with the correct input to return control back to the test + _, err = console.SendLine(requiredValue) + require.NoError(t, err) + }() + + // An err indicates that the confirmation text sent did not match + err := p.ConfirmDeletion(requiredValue) + require.NoError(t, err) + }) + t.Run("InputHostname", func(t *testing.T) { hostname := "somethingdoesnotmatter.com" go func() { From 4cf048a8d17bf87ee9717756c9877fb5d5b77169 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 4 Apr 2025 11:33:10 -0600 Subject: [PATCH 024/147] fix(prompter): input returns default when blank --- internal/prompter/prompter.go | 5 +++++ ...eech_synthesizer_friendly_prompter_test.go | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index f9146d8b7..23f967e13 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -125,6 +125,7 @@ func (p *speechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults func (p *speechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) (string, error) { result := defaultValue + prompt = fmt.Sprintf("%s (%s)", prompt, defaultValue) form := p.newForm( huh.NewGroup( huh.NewInput(). @@ -134,6 +135,10 @@ func (p *speechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) ( ) err := form.Run() + + if result == "" { + return defaultValue, nil + } return result, err } diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 119106f64..8f41de973 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -116,6 +116,28 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { assert.Equal(t, dummyText, inputValue) }) + t.Run("Input - blank input returns default value", func(t *testing.T) { + dummyDefaultValue := "12345abcdefg" + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter some characters") + require.NoError(t, err) + + // Enter nothing + _, err = console.SendLine("") + require.NoError(t, err) + + // Expect the default value to be returned + _, err = console.ExpectString(dummyDefaultValue) + require.NoError(t, err) + }() + + inputValue, err := p.Input("Enter some characters", dummyDefaultValue) + require.NoError(t, err) + + assert.Equal(t, dummyDefaultValue, inputValue) + }) + t.Run("Password", func(t *testing.T) { dummyPassword := "12345abcdefg" go func() { From 5c39e0bd10bc33808411e62ddda679131e9217b4 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 4 Apr 2025 14:52:25 -0600 Subject: [PATCH 025/147] fix(prompter): notes about Confirm default --- internal/prompter/prompter.go | 7 +++++-- ...eech_synthesizer_friendly_prompter_test.go | 21 +++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 23f967e13..2e6f7fe4f 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -158,8 +158,11 @@ func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, err return result, err } -func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, _ bool) (bool, error) { - var result bool +func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { + // This is currently an inneffectual assignment because the value is + // not respected as the default in accessible mode. + // See https://github.com/charmbracelet/huh/issues/615 + result := defaultValue form := p.newForm( huh.NewGroup( huh.NewConfirm(). diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 8f41de973..4091db33a 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -171,8 +171,25 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { require.Equal(t, true, confirmValue) }) - // TODO: Need one that enters invalid input - // TODO: write tests for control-c + // This test currently fails because the value is + // not respected as the default in accessible mode. + // See https://github.com/charmbracelet/huh/issues/615 + // t.Run("Confirm - blank input returns default", func(t *testing.T) { + // go func() { + // // Wait for prompt to appear + // _, err := console.ExpectString("Are you sure") + // require.NoError(t, err) + + // // Enter nothing + // _, err = console.SendLine("") + // require.NoError(t, err) + // }() + + // confirmValue, err := p.Confirm("Are you sure", false) + // require.NoError(t, err) + // require.Equal(t, false, confirmValue) + // }) + t.Run("AuthToken", func(t *testing.T) { dummyAuthToken := "12345abcdefg" go func() { From 2e48cadf581b122860e4c9e2f83d0dbf588fe0a6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 4 Apr 2025 15:05:23 -0600 Subject: [PATCH 026/147] fix(prompter): remove impossible condition --- internal/prompter/prompter.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 2e6f7fe4f..fc2b3fe32 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -258,9 +258,6 @@ func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue } if result == "skip" { - if !blankAllowed && defaultValue == "" { - panic("blank not allowed and no default value") - } return "", nil } From 0b49522467c9122cb7644fb007f5d22db72874e3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 4 Apr 2025 15:07:21 -0600 Subject: [PATCH 027/147] refactor(prompter): less magic strings --- internal/prompter/prompter.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index fc2b3fe32..67ae0fa6a 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -237,11 +237,13 @@ func (p *speechSynthesizerFriendlyPrompter) InputHostname() (string, error) { func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string + skipOption := "skip" + openOption := "open" options := []huh.Option[string]{ - huh.NewOption(fmt.Sprintf("Open Editor: %s", p.editorCmd), "open"), + huh.NewOption(fmt.Sprintf("Open Editor: %s", p.editorCmd), openOption), } if blankAllowed { - options = append(options, huh.NewOption("Skip", "skip")) + options = append(options, huh.NewOption("Skip", skipOption)) } form := p.newForm( @@ -257,10 +259,11 @@ func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue return "", err } - if result == "skip" { + if result == skipOption { return "", nil } + // openOption was selected text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr) if err != nil { return "", err From f89700160b3fcc8f7c4d2f2ef3128063fe2cd8fc Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 4 Apr 2025 15:08:17 -0600 Subject: [PATCH 028/147] doc(prompter): clarify comments --- internal/prompter/prompter.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 67ae0fa6a..0a42a8df9 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -160,7 +160,8 @@ func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, err func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { // This is currently an inneffectual assignment because the value is - // not respected as the default in accessible mode. + // not respected as the default in accessible mode. Leaving this in here + // because it may change in the future. // See https://github.com/charmbracelet/huh/issues/615 result := defaultValue form := p.newForm( From 918cafc222aa328c1fc932672c51027f9b39060d Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Sun, 6 Apr 2025 10:18:48 -0400 Subject: [PATCH 029/147] Deprecate ColorScheme.Gray for ColorScheme.Muted This commit converts all of the places using ColorScheme.Gray and ColorScheme.Grayf to Muted and Mutedf. There is a little extra tidying up with local variable names or converting code to use Mutedf format. --- pkg/cmd/gist/create/create.go | 2 +- pkg/cmd/gist/shared/shared.go | 2 +- pkg/cmd/gist/view/view.go | 4 ++-- pkg/cmd/issue/shared/display.go | 4 ++-- pkg/cmd/issue/view/view.go | 4 ++-- pkg/cmd/pr/checks/output.go | 2 +- pkg/cmd/pr/create/create.go | 26 +++++++++++++------------- pkg/cmd/pr/review/review.go | 2 +- pkg/cmd/pr/shared/comments.go | 8 ++++---- pkg/cmd/pr/shared/display.go | 4 ++-- pkg/cmd/pr/status/status.go | 2 +- pkg/cmd/pr/view/view.go | 4 ++-- pkg/cmd/release/view/view.go | 16 ++++++++-------- pkg/cmd/repo/license/view/view.go | 6 +++--- pkg/cmd/repo/view/view.go | 6 +++--- pkg/cmd/run/shared/presentation.go | 2 +- pkg/cmd/run/shared/shared.go | 2 +- pkg/cmd/run/view/view.go | 6 +++--- pkg/cmd/search/commits/commits.go | 2 +- pkg/cmd/search/repos/repos.go | 4 ++-- pkg/cmd/search/shared/shared.go | 2 +- pkg/cmd/status/status.go | 2 +- pkg/cmd/workflow/view/view.go | 2 +- pkg/iostreams/color.go | 6 +++--- 24 files changed, 60 insertions(+), 60 deletions(-) diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index 5392b997e..4f51bed25 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -138,7 +138,7 @@ func createRun(opts *CreateOptions) error { processMessage = fmt.Sprintf("Creating gist %s", gistName) } } - fmt.Fprintf(errOut, "%s %s\n", cs.Gray("-"), processMessage) + fmt.Fprintf(errOut, "%s %s\n", cs.Muted("-"), processMessage) httpClient, err := opts.HttpClient() if err != nil { diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go index 53b577e4c..fc63f56ce 100644 --- a/pkg/cmd/gist/shared/shared.go +++ b/pkg/cmd/gist/shared/shared.go @@ -230,7 +230,7 @@ func PromptGists(prompter prompter.Prompter, client *http.Client, host string, c for i, gist := range gists { gistTime := text.FuzzyAgo(time.Now(), gist.UpdatedAt) // TODO: support dynamic maxWidth - opts[i] = fmt.Sprintf("%s %s %s", cs.Bold(gist.Filename()), gist.TruncDescription(), cs.Gray(gistTime)) + opts[i] = fmt.Sprintf("%s %s %s", cs.Bold(gist.Filename()), gist.TruncDescription(), cs.Muted(gistTime)) } result, err := prompter.Select("Select a gist", "", opts) diff --git a/pkg/cmd/gist/view/view.go b/pkg/cmd/gist/view/view.go index 705f8f703..f789c5b04 100644 --- a/pkg/cmd/gist/view/view.go +++ b/pkg/cmd/gist/view/view.go @@ -140,7 +140,7 @@ func viewRun(opts *ViewOptions) error { if len(gist.Files) == 1 || opts.Filename != "" { return fmt.Errorf("error: file is binary") } - _, err = fmt.Fprintln(opts.IO.Out, cs.Gray("(skipping rendering binary content)")) + _, err = fmt.Fprintln(opts.IO.Out, cs.Muted("(skipping rendering binary content)")) return nil } @@ -197,7 +197,7 @@ func viewRun(opts *ViewOptions) error { for i, fn := range filenames { if showFilenames { - fmt.Fprintf(opts.IO.Out, "%s\n\n", cs.Gray(fn)) + fmt.Fprintf(opts.IO.Out, "%s\n\n", cs.Muted(fn)) } if err := render(gist.Files[fn]); err != nil { return err diff --git a/pkg/cmd/issue/shared/display.go b/pkg/cmd/issue/shared/display.go index 0c56ffd2c..08ec484d6 100644 --- a/pkg/cmd/issue/shared/display.go +++ b/pkg/cmd/issue/shared/display.go @@ -38,13 +38,13 @@ func PrintIssues(io *iostreams.IOStreams, now time.Time, prefix string, totalCou } table.AddField(text.RemoveExcessiveWhitespace(issue.Title)) table.AddField(issueLabelList(&issue, cs, isTTY)) - table.AddTimeField(now, issue.UpdatedAt, cs.Gray) + table.AddTimeField(now, issue.UpdatedAt, cs.Muted) table.EndRow() } _ = table.Render() remaining := totalCount - len(issues) if remaining > 0 { - fmt.Fprintf(io.Out, cs.Gray("%sAnd %d more\n"), prefix, remaining) + fmt.Fprintf(io.Out, cs.Muted("%sAnd %d more\n"), prefix, remaining) } } diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index b188c6a4c..f61c603b4 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -228,7 +228,7 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue var md string var err error if issue.Body == "" { - md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided")) + md = fmt.Sprintf("\n %s\n\n", cs.Muted("No description provided")) } else { md, err = markdown.Render(issue.Body, markdown.WithTheme(opts.IO.TerminalTheme()), @@ -250,7 +250,7 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue } // Footer - fmt.Fprintf(out, cs.Gray("View this issue on GitHub: %s\n"), issue.URL) + fmt.Fprintf(out, cs.Muted("View this issue on GitHub: %s\n"), issue.URL) return nil } diff --git a/pkg/cmd/pr/checks/output.go b/pkg/cmd/pr/checks/output.go index 5d2f080c6..24105c3e2 100644 --- a/pkg/cmd/pr/checks/output.go +++ b/pkg/cmd/pr/checks/output.go @@ -30,7 +30,7 @@ func addRow(tp *tableprinter.TablePrinter, io *iostreams.IOStreams, o check) { markColor = cs.Yellow case "skipping", "cancel": mark = "-" - markColor = cs.Gray + markColor = cs.Muted } if io.IsStdoutTTY() { diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 0066bbc6e..5f8979c11 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -879,37 +879,37 @@ func renderPullRequestPlain(w io.Writer, params map[string]interface{}, state *s } func renderPullRequestTTY(io *iostreams.IOStreams, params map[string]interface{}, state *shared.IssueMetadataState) error { - iofmt := io.ColorScheme() + cs := io.ColorScheme() out := io.Out fmt.Fprint(out, "Would have created a Pull Request with:\n") - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Title"), params["title"].(string)) - fmt.Fprintf(out, "%s: %t\n", iofmt.Bold("Draft"), params["draft"]) - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Base"), params["baseRefName"]) - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Head"), params["headRefName"]) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Title"), params["title"].(string)) + fmt.Fprintf(out, "%s: %t\n", cs.Bold("Draft"), params["draft"]) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Base"), params["baseRefName"]) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Head"), params["headRefName"]) if len(state.Labels) != 0 { - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Labels"), strings.Join(state.Labels, ", ")) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Labels"), strings.Join(state.Labels, ", ")) } if len(state.Reviewers) != 0 { - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Reviewers"), strings.Join(state.Reviewers, ", ")) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Reviewers"), strings.Join(state.Reviewers, ", ")) } if len(state.Assignees) != 0 { - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Assignees"), strings.Join(state.Assignees, ", ")) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Assignees"), strings.Join(state.Assignees, ", ")) } if len(state.Milestones) != 0 { - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Milestones"), strings.Join(state.Milestones, ", ")) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Milestones"), strings.Join(state.Milestones, ", ")) } if len(state.Projects) != 0 { - fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Projects"), strings.Join(state.Projects, ", ")) + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Projects"), strings.Join(state.Projects, ", ")) } - fmt.Fprintf(out, "%s: %t\n", iofmt.Bold("MaintainerCanModify"), params["maintainerCanModify"]) + fmt.Fprintf(out, "%s: %t\n", cs.Bold("MaintainerCanModify"), params["maintainerCanModify"]) - fmt.Fprintf(out, "%s\n", iofmt.Bold("Body:")) + fmt.Fprintf(out, "%s\n", cs.Bold("Body:")) // Body var md string var err error if len(params["body"].(string)) == 0 { - md = fmt.Sprintf("%s\n", iofmt.Gray("No description provided")) + md = fmt.Sprintf("%s\n", cs.Muted("No description provided")) } else { md, err = markdown.Render(params["body"].(string), markdown.WithTheme(io.TerminalTheme()), diff --git a/pkg/cmd/pr/review/review.go b/pkg/cmd/pr/review/review.go index 25f81d973..cafa6ce8f 100644 --- a/pkg/cmd/pr/review/review.go +++ b/pkg/cmd/pr/review/review.go @@ -191,7 +191,7 @@ func reviewRun(opts *ReviewOptions) error { switch reviewData.State { case api.ReviewComment: - fmt.Fprintf(opts.IO.ErrOut, "%s Reviewed pull request %s#%d\n", cs.Gray("-"), ghrepo.FullName(baseRepo), pr.Number) + fmt.Fprintf(opts.IO.ErrOut, "%s Reviewed pull request %s#%d\n", cs.Muted("-"), ghrepo.FullName(baseRepo), pr.Number) case api.ReviewApprove: fmt.Fprintf(opts.IO.ErrOut, "%s Approved pull request %s#%d\n", cs.SuccessIcon(), ghrepo.FullName(baseRepo), pr.Number) case api.ReviewRequestChanges: diff --git a/pkg/cmd/pr/shared/comments.go b/pkg/cmd/pr/shared/comments.go index a05108d7b..7c6e9154c 100644 --- a/pkg/cmd/pr/shared/comments.go +++ b/pkg/cmd/pr/shared/comments.go @@ -62,7 +62,7 @@ func CommentList(io *iostreams.IOStreams, comments api.Comments, reviews api.Pul hiddenCount := totalCount - retrievedCount if preview && hiddenCount > 0 { - fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Not showing %s ————————", text.Pluralize(hiddenCount, "comment")))) + fmt.Fprint(&b, cs.Muted(fmt.Sprintf("———————— Not showing %s ————————", text.Pluralize(hiddenCount, "comment")))) fmt.Fprintf(&b, "\n\n\n") } @@ -79,7 +79,7 @@ func CommentList(io *iostreams.IOStreams, comments api.Comments, reviews api.Pul } if preview && hiddenCount > 0 { - fmt.Fprint(&b, cs.Gray("Use --comments to view the full conversation")) + fmt.Fprint(&b, cs.Muted("Use --comments to view the full conversation")) fmt.Fprintln(&b) } @@ -122,7 +122,7 @@ func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (strin var md string var err error if comment.Content() == "" { - md = fmt.Sprintf("\n %s\n\n", cs.Gray("No body provided")) + md = fmt.Sprintf("\n %s\n\n", cs.Muted("No body provided")) } else { md, err = markdown.Render(comment.Content(), markdown.WithTheme(io.TerminalTheme()), @@ -135,7 +135,7 @@ func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (strin // Footer if comment.Link() != "" { - fmt.Fprintf(&b, cs.Gray("View the full review: %s\n\n"), comment.Link()) + fmt.Fprintf(&b, cs.Muted("View the full review: %s\n\n"), comment.Link()) } return b.String(), nil diff --git a/pkg/cmd/pr/shared/display.go b/pkg/cmd/pr/shared/display.go index 02482951c..b4d83c719 100644 --- a/pkg/cmd/pr/shared/display.go +++ b/pkg/cmd/pr/shared/display.go @@ -60,7 +60,7 @@ func PrintHeader(io *iostreams.IOStreams, s string) { } func PrintMessage(io *iostreams.IOStreams, s string) { - fmt.Fprintln(io.Out, io.ColorScheme().Gray(s)) + fmt.Fprintln(io.Out, io.ColorScheme().Muted(s)) } func ListNoResults(repoName string, itemName string, hasFilters bool) error { @@ -83,7 +83,7 @@ func ListHeader(repoName string, itemName string, matchCount int, totalMatchCoun } func PrCheckStatusSummaryWithColor(cs *iostreams.ColorScheme, checks api.PullRequestChecksStatus) string { - var summary = cs.Gray("No checks") + var summary = cs.Muted("No checks") if checks.Total > 0 { if checks.Failing > 0 { if checks.Failing == checks.Total { diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index b7b390bf2..d20522d04 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -316,6 +316,6 @@ func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) { } remaining := totalCount - len(prs) if remaining > 0 { - fmt.Fprintf(w, cs.Gray(" And %d more\n"), remaining) + fmt.Fprintf(w, cs.Muted(" And %d more\n"), remaining) } } diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index ccad4fa77..ed5984b46 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -260,7 +260,7 @@ func printHumanPrPreview(opts *ViewOptions, baseRepo ghrepo.Interface, pr *api.P var md string var err error if pr.Body == "" { - md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided")) + md = fmt.Sprintf("\n %s\n\n", cs.Muted("No description provided")) } else { md, err = markdown.Render(pr.Body, markdown.WithTheme(opts.IO.TerminalTheme()), @@ -282,7 +282,7 @@ func printHumanPrPreview(opts *ViewOptions, baseRepo ghrepo.Interface, pr *api.P } // Footer - fmt.Fprintf(out, cs.Gray("View this pull request on GitHub: %s\n"), pr.URL) + fmt.Fprintf(out, cs.Muted("View this pull request on GitHub: %s\n"), pr.URL) return nil } diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index a32482e65..db0e6ae1d 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -129,19 +129,19 @@ func viewRun(opts *ViewOptions) error { } func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { - iofmt := io.ColorScheme() + cs := io.ColorScheme() w := io.Out - fmt.Fprintf(w, "%s\n", iofmt.Bold(release.TagName)) + fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) if release.IsDraft { - fmt.Fprintf(w, "%s • ", iofmt.Red("Draft")) + fmt.Fprintf(w, "%s • ", cs.Red("Draft")) } else if release.IsPrerelease { - fmt.Fprintf(w, "%s • ", iofmt.Yellow("Pre-release")) + fmt.Fprintf(w, "%s • ", cs.Yellow("Pre-release")) } if release.IsDraft { - fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt)))) + fmt.Fprintf(w, "%s\n", cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))) } else { - fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt)))) + fmt.Fprintf(w, "%s\n", cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))) } renderedDescription, err := markdown.Render(release.Body, @@ -153,7 +153,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { fmt.Fprintln(w, renderedDescription) if len(release.Assets) > 0 { - fmt.Fprintf(w, "%s\n", iofmt.Bold("Assets")) + fmt.Fprintf(w, "%s\n", cs.Bold("Assets")) //nolint:staticcheck // SA1019: Showing NAME|SIZE headers adds nothing to table. table := tableprinter.New(io, tableprinter.NoHeader) for _, a := range release.Assets { @@ -168,7 +168,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { fmt.Fprint(w, "\n") } - fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("View on GitHub: %s", release.URL))) + fmt.Fprintln(w, cs.Mutedf("View on GitHub: %s", release.URL)) return nil } diff --git a/pkg/cmd/repo/license/view/view.go b/pkg/cmd/repo/license/view/view.go index d3e63c241..14228ba36 100644 --- a/pkg/cmd/repo/license/view/view.go +++ b/pkg/cmd/repo/license/view/view.go @@ -119,9 +119,9 @@ func renderLicense(license *api.License, opts *ViewOptions) error { cs := opts.IO.ColorScheme() var out strings.Builder if opts.IO.IsStdoutTTY() { - out.WriteString(fmt.Sprintf("\n%s\n", cs.Gray(license.Description))) - out.WriteString(fmt.Sprintf("\n%s\n", cs.Grayf("To implement: %s", license.Implementation))) - out.WriteString(fmt.Sprintf("\n%s\n\n", cs.Grayf("For more information, see: %s", license.HTMLURL))) + out.WriteString(fmt.Sprintf("\n%s\n", cs.Muted(license.Description))) + out.WriteString(fmt.Sprintf("\n%s\n", cs.Mutedf("To implement: %s", license.Implementation))) + out.WriteString(fmt.Sprintf("\n%s\n\n", cs.Mutedf("For more information, see: %s", license.HTMLURL))) } out.WriteString(license.Body) _, err := opts.IO.Out.Write([]byte(out.String())) diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go index 06f85d048..b13276c80 100644 --- a/pkg/cmd/repo/view/view.go +++ b/pkg/cmd/repo/view/view.go @@ -181,7 +181,7 @@ func viewRun(opts *ViewOptions) error { var readmeContent string if readme == nil { - readmeContent = cs.Gray("This repository does not have a README") + readmeContent = cs.Muted("This repository does not have a README") } else if isMarkdownFile(readme.Filename) { var err error readmeContent, err = markdown.Render(readme.Content, @@ -197,7 +197,7 @@ func viewRun(opts *ViewOptions) error { description := repo.Description if description == "" { - description = cs.Gray("No description provided") + description = cs.Muted("No description provided") } repoData := struct { @@ -209,7 +209,7 @@ func viewRun(opts *ViewOptions) error { FullName: cs.Bold(fullName), Description: description, Readme: readmeContent, - View: cs.Gray(fmt.Sprintf("View this repository on GitHub: %s", openURL)), + View: cs.Mutedf("View this repository on GitHub: %s", openURL), } return tmpl.Execute(stdout, repoData) diff --git a/pkg/cmd/run/shared/presentation.go b/pkg/cmd/run/shared/presentation.go index 2ec149729..699ea120f 100644 --- a/pkg/cmd/run/shared/presentation.go +++ b/pkg/cmd/run/shared/presentation.go @@ -52,7 +52,7 @@ func RenderAnnotations(cs *iostreams.ColorScheme, annotations []Annotation) stri for _, a := range annotations { lines = append(lines, fmt.Sprintf("%s %s", AnnotationSymbol(cs, a), a.Message)) - lines = append(lines, cs.Grayf("%s: %s#%d\n", a.JobName, a.Path, a.StartLine)) + lines = append(lines, cs.Mutedf("%s: %s#%d\n", a.JobName, a.Path, a.StartLine)) } return strings.Join(lines, "\n") diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index ce909fd77..8dbf59c41 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -575,7 +575,7 @@ func Symbol(cs *iostreams.ColorScheme, status Status, conclusion Conclusion) (st case Success: return cs.SuccessIconWithColor(noColor), cs.Green case Skipped, Neutral: - return "-", cs.Gray + return "-", cs.Muted default: return cs.FailureIconWithColor(noColor), cs.Red } diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index c794cff9a..d308962ed 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -397,7 +397,7 @@ func runView(opts *ViewOptions) error { for _, a := range artifacts { expiredBadge := "" if a.Expired { - expiredBadge = cs.Gray(" (expired)") + expiredBadge = cs.Muted(" (expired)") } fmt.Fprintf(out, "%s%s\n", a.Name, expiredBadge) } @@ -411,7 +411,7 @@ func runView(opts *ViewOptions) error { } else { fmt.Fprintf(out, "For more information about a job, try: gh run view --job=\n") } - fmt.Fprintf(out, cs.Gray("View this run on GitHub: %s\n"), run.URL) + fmt.Fprintln(out, cs.Mutedf("View this run on GitHub: %s", run.URL)) if opts.ExitStatus && shared.IsFailureState(run.Conclusion) { return cmdutil.SilentError @@ -423,7 +423,7 @@ func runView(opts *ViewOptions) error { } else { fmt.Fprintf(out, "To see the full job log, try: gh run view --log --job=%d\n", selectedJob.ID) } - fmt.Fprintf(out, cs.Gray("View this run on GitHub: %s\n"), run.URL) + fmt.Fprintln(out, cs.Mutedf("View this run on GitHub: %s", run.URL)) if opts.ExitStatus && shared.IsFailureState(selectedJob.Conclusion) { return cmdutil.SilentError diff --git a/pkg/cmd/search/commits/commits.go b/pkg/cmd/search/commits/commits.go index bc37684a2..fb1742dc9 100644 --- a/pkg/cmd/search/commits/commits.go +++ b/pkg/cmd/search/commits/commits.go @@ -161,7 +161,7 @@ func displayResults(io *iostreams.IOStreams, now time.Time, results search.Commi tp.AddField(commit.Sha) tp.AddField(text.RemoveExcessiveWhitespace(commit.Info.Message)) tp.AddField(commit.Author.Login) - tp.AddTimeField(now, commit.Info.Author.Date, cs.Gray) + tp.AddTimeField(now, commit.Info.Author.Date, cs.Muted) tp.EndRow() } if io.IsStdoutTTY() { diff --git a/pkg/cmd/search/repos/repos.go b/pkg/cmd/search/repos/repos.go index 0bad650d3..2815ee6dc 100644 --- a/pkg/cmd/search/repos/repos.go +++ b/pkg/cmd/search/repos/repos.go @@ -171,14 +171,14 @@ func displayResults(io *iostreams.IOStreams, now time.Time, results search.Repos tags = append(tags, "archived") } info := strings.Join(tags, ", ") - infoColor := cs.Gray + infoColor := cs.Muted if repo.IsPrivate { infoColor = cs.Yellow } tp.AddField(repo.FullName, tableprinter.WithColor(cs.Bold)) tp.AddField(text.RemoveExcessiveWhitespace(repo.Description)) tp.AddField(info, tableprinter.WithColor(infoColor)) - tp.AddTimeField(now, repo.UpdatedAt, cs.Gray) + tp.AddTimeField(now, repo.UpdatedAt, cs.Muted) tp.EndRow() } if io.IsStdoutTTY() { diff --git a/pkg/cmd/search/shared/shared.go b/pkg/cmd/search/shared/shared.go index f0a346fc8..aefd0c054 100644 --- a/pkg/cmd/search/shared/shared.go +++ b/pkg/cmd/search/shared/shared.go @@ -132,7 +132,7 @@ func displayIssueResults(io *iostreams.IOStreams, now time.Time, et EntityType, } tp.AddField(text.RemoveExcessiveWhitespace(issue.Title)) tp.AddField(listIssueLabels(&issue, cs, tp.IsTTY())) - tp.AddTimeField(now, issue.UpdatedAt, cs.Gray) + tp.AddTimeField(now, issue.UpdatedAt, cs.Muted) tp.EndRow() } diff --git a/pkg/cmd/status/status.go b/pkg/cmd/status/status.go index 0ed7dd3a7..c9acce8bd 100644 --- a/pkg/cmd/status/status.go +++ b/pkg/cmd/status/status.go @@ -740,7 +740,7 @@ func statusRun(opts *StatusOptions) error { errs := sg.authErrors.ToSlice() sort.Strings(errs) for _, msg := range errs { - fmt.Fprintln(out, cs.Gray(fmt.Sprintf("warning: %s", msg))) + fmt.Fprintln(out, cs.Mutedf("warning: %s", msg)) } } diff --git a/pkg/cmd/workflow/view/view.go b/pkg/cmd/workflow/view/view.go index 188d79e22..2e550f496 100644 --- a/pkg/cmd/workflow/view/view.go +++ b/pkg/cmd/workflow/view/view.go @@ -175,7 +175,7 @@ func viewWorkflowContent(opts *ViewOptions, client *api.Client, repo ghrepo.Inte out := opts.IO.Out fileName := workflow.Base() - fmt.Fprintf(out, "%s - %s\n", cs.Bold(workflow.Name), cs.Gray(fileName)) + fmt.Fprintf(out, "%s - %s\n", cs.Bold(workflow.Name), cs.Muted(fileName)) fmt.Fprintf(out, "ID: %s", cs.Cyanf("%d", workflow.ID)) codeBlock := fmt.Sprintf("```yaml\n%s\n```", yaml) diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index 58d13e6ef..a92fdcab3 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -142,7 +142,7 @@ func (c *ColorScheme) GreenBold(t string) string { return greenBold(t) } -// Use Muted instead for thematically contrasting color. +// Deprecated: Use Muted instead for thematically contrasting color. func (c *ColorScheme) Gray(t string) string { if !c.enabled { return t @@ -153,7 +153,7 @@ func (c *ColorScheme) Gray(t string) string { return gray(t) } -// Use Mutedf instead for thematically contrasting color. +// Deprecated: Use Mutedf instead for thematically contrasting color. func (c *ColorScheme) Grayf(t string, args ...interface{}) string { return c.Gray(fmt.Sprintf(t, args...)) } @@ -255,7 +255,7 @@ func (c *ColorScheme) ColorFromString(s string) func(string) string { case "green": fn = c.Green case "gray": - fn = c.Gray + fn = c.Muted case "magenta": fn = c.Magenta case "cyan": From 2582948d5ffc282b475f88686eed9a77cb8fa7b2 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Sun, 6 Apr 2025 22:36:09 +0100 Subject: [PATCH 030/147] Extract job name sanitization as a separate function Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/view.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index c794cff9a..39c9de3c1 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -533,7 +533,7 @@ func promptForJob(prompter shared.Prompter, cs *iostreams.ColorScheme, jobs []sh const JOB_NAME_MAX_LENGTH = 90 -func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp { +func getJobNameForLogFilename(name string) string { // As described in https://github.com/cli/cli/issues/5011#issuecomment-1570713070, there are a number of steps // the server can take when producing the downloaded zip file that can result in a mismatch between the job name // and the filename in the zip including: @@ -545,9 +545,14 @@ func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp { // * Strip `/` which occur when composite action job names are constructed of the form ` / ` // * Truncate long job names // - sanitizedJobName := strings.ReplaceAll(job.Name, "/", "") + sanitizedJobName := strings.ReplaceAll(name, "/", "") sanitizedJobName = strings.ReplaceAll(sanitizedJobName, ":", "") sanitizedJobName = truncateAsUTF16(sanitizedJobName, JOB_NAME_MAX_LENGTH) + return sanitizedJobName +} + +func stepLogFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp { + sanitizedJobName := getJobNameForLogFilename(job.Name) re := fmt.Sprintf(`^%s\/%d_.*\.txt`, regexp.QuoteMeta(sanitizedJobName), step.Number) return regexp.MustCompile(re) } @@ -637,7 +642,7 @@ func truncateAsUTF16(str string, max int) string { func attachRunLog(rlz *zip.Reader, jobs []shared.Job) { for i, job := range jobs { for j, step := range job.Steps { - re := logFilenameRegexp(job, step) + re := stepLogFilenameRegexp(job, step) for _, file := range rlz.File { if re.MatchString(file.Name) { jobs[i].Steps[j].Log = file From 4dee1c3c98342b406b2e7940044921ccf2c80aa4 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 7 Apr 2025 12:59:49 +0100 Subject: [PATCH 031/147] Add `jobLogFilenameRegexp` function Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/view.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 39c9de3c1..2cfe26db6 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -551,6 +551,12 @@ func getJobNameForLogFilename(name string) string { return sanitizedJobName } +func jobLogFilenameRegexp(job shared.Job) *regexp.Regexp { + sanitizedJobName := getJobNameForLogFilename(job.Name) + re := fmt.Sprintf(`^-?\d+_%s\.txt`, regexp.QuoteMeta(sanitizedJobName)) + return regexp.MustCompile(re) +} + func stepLogFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp { sanitizedJobName := getJobNameForLogFilename(job.Name) re := fmt.Sprintf(`^%s\/%d_.*\.txt`, regexp.QuoteMeta(sanitizedJobName), step.Number) From f7efdde5ef3606f6b604bbbc333cbbb420066771 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 7 Apr 2025 13:51:56 +0100 Subject: [PATCH 032/147] Add `Log` to `Job` data structure Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/shared/shared.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index ce909fd77..33882349e 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -230,6 +230,8 @@ type Job struct { CompletedAt time.Time `json:"completed_at"` URL string `json:"html_url"` RunID int64 `json:"run_id"` + + Log *zip.File } type Step struct { @@ -239,7 +241,8 @@ type Step struct { Number int StartedAt time.Time `json:"started_at"` CompletedAt time.Time `json:"completed_at"` - Log *zip.File + + Log *zip.File } type Steps []Step From 5e78832a7ea2535ecf844f0db9ca6f9f1393ef7f Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 7 Apr 2025 13:01:48 +0100 Subject: [PATCH 033/147] Fallback to print entire job run log if step logs are missing Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/view.go | 64 +++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 2cfe26db6..718d5b902 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -638,15 +638,31 @@ func truncateAsUTF16(str string, max int) string { // │ ├── 2_anotherstepname.txt // │ ├── 3_stepstepname.txt // │ └── 4_laststepname.txt -// └── jobname2/ -// ├── 1_stepname.txt -// └── 2_somestepname.txt +// ├── jobname2/ +// | ├── 1_stepname.txt +// | └── 2_somestepname.txt +// ├── 0_jobname1.txt +// ├── 1_jobname2.txt +// └── -9999999999_jobname3.txt // // It iterates through the list of jobs and tries to find the matching // log in the zip file. If the matching log is found it is attached // to the job. +// +// The top-level .txt files include the logs for an entire job run. Note that +// the prefixed number is either: +// - An ordinal and cannot be mapped to the corresponding job's ID. +// - A negative integer which is the ID of the job in the old Actions service. func attachRunLog(rlz *zip.Reader, jobs []shared.Job) { for i, job := range jobs { + re := jobLogFilenameRegexp(job) + for _, file := range rlz.File { + if re.MatchString(file.Name) { + jobs[i].Log = file + break + } + } + for j, step := range job.Steps { re := stepLogFilenameRegexp(job, step) for _, file := range rlz.File { @@ -661,6 +677,8 @@ func attachRunLog(rlz *zip.Reader, jobs []shared.Job) { func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error { for _, job := range jobs { + var hasStepLogs bool + steps := job.Steps sort.Sort(steps) for _, step := range steps { @@ -670,18 +688,44 @@ func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error { if step.Log == nil { continue } + hasStepLogs = true prefix := fmt.Sprintf("%s\t%s\t", job.Name, step.Name) - f, err := step.Log.Open() - if err != nil { + if err := printZIPFile(w, step.Log, prefix); err != nil { return err } - scanner := bufio.NewScanner(f) - for scanner.Scan() { - fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text()) - } - f.Close() + } + + if hasStepLogs { + continue + } + + if failed && !shared.IsFailureState(job.Conclusion) { + continue + } + + if job.Log == nil { + continue + } + + prefix := fmt.Sprintf("%s\tUNKNOWN\t", job.Name) + if err := printZIPFile(w, job.Log, prefix); err != nil { + return err } } return nil } + +func printZIPFile(w io.Writer, file *zip.File, prefix string) error { + f, err := file.Open() + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text()) + } + return nil +} From df8c9a317dfef8ed80bd68b9bd45e63fd19d4f97 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 7 Apr 2025 13:47:01 +0100 Subject: [PATCH 034/147] Update `run_log.zip` fixture Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/fixtures/run_log.zip | Bin 6880 -> 8148 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/pkg/cmd/run/view/fixtures/run_log.zip b/pkg/cmd/run/view/fixtures/run_log.zip index 757a6398307d835f12293e64b602d58ebc965308..60701d9254cbcacdda1d9aff426de744b6d574d7 100644 GIT binary patch delta 1175 zcmaE0dc}Ujdp;Lt77+#p1`dY98SPP8PIt@2fjlQ5=3|gyFo;ji&(BfF%1_cOsVE5z z;bdT5xBqiG2$xoHGcdBeU}j(d5|b~oig>XIPiJBX@P?Up4$VAXpm~Pz#fd2>#vKM4 z1;QA{#iAK^Yi(PUz@w6EkYg2rSOI995!`tS<(VZJ3VHbo#U-f)3OV`d#c&7Py!e#f zJ{{8r1z(nRs7A^IjWmY25RZxNxJ-=qi*848q#@8mT}wEK&q+;BOs-Ub1u7mpf>(S_ zH{OHA)p1fOsCF1&w*wl`c&vE5{c}19qX)gj=J$Mex$7C32 zr4{u^$s5%g%w&UXjf4OL;Y0+q1(b;JgpXf6Mp8nyr4?uk83_w$FDPN*u@|1Y%7Kv! q%4Y@)$o8@bVzrk*Vgp)>k Date: Mon, 7 Apr 2025 13:50:27 +0100 Subject: [PATCH 035/147] Verify job run logs attached in `attachRunLog` test Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/view_test.go | 141 ++++++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 32 deletions(-) diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index 3a04fb186..ea0b92f40 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -1419,14 +1419,23 @@ func TestViewRun(t *testing.T) { // ├── sad job/ // │ ├── 1_barf the quux.txt // │ └── 2_quux the barf.txt -// └── ad job/ -// └── 1_barf the quux.txt +// ├── ad job/ +// | └── 1_barf the quux.txt +// ├── 0_cool job.txt +// ├── 1_sad job.txt +// ├── 2_cool job with no step logs.txt +// ├── 3_sad job with no step logs.txt +// ├── -9999999999_legacy cool job with no step logs.txt +// └── -9999999999_legacy sad job with no step logs.txt + func Test_attachRunLog(t *testing.T) { tests := []struct { - name string - job shared.Job - wantMatch bool - wantFilename string + name string + job shared.Job + wantJobMatch bool + wantJobFilename string + wantStepMatch bool + wantStepFilename string }{ { name: "matching job name and step number 1", @@ -1437,8 +1446,10 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, - wantFilename: "cool job/1_fob the barz.txt", + wantJobMatch: true, + wantJobFilename: "0_cool job.txt", + wantStepMatch: true, + wantStepFilename: "cool job/1_fob the barz.txt", }, { name: "matching job name and step number 2", @@ -1449,8 +1460,10 @@ func Test_attachRunLog(t *testing.T) { Number: 2, }}, }, - wantMatch: true, - wantFilename: "cool job/2_barz the fob.txt", + wantJobMatch: true, + wantJobFilename: "0_cool job.txt", + wantStepMatch: true, + wantStepFilename: "cool job/2_barz the fob.txt", }, { name: "matching job name and step number and mismatch step name", @@ -1461,8 +1474,10 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, - wantFilename: "cool job/1_fob the barz.txt", + wantJobMatch: true, + wantJobFilename: "0_cool job.txt", + wantStepMatch: true, + wantStepFilename: "cool job/1_fob the barz.txt", }, { name: "matching job name and mismatch step number", @@ -1473,7 +1488,53 @@ func Test_attachRunLog(t *testing.T) { Number: 3, }}, }, - wantMatch: false, + wantJobMatch: true, + wantJobFilename: "0_cool job.txt", + wantStepMatch: false, + }, + { + name: "matching job name with no step logs", + job: shared.Job{ + Name: "cool job with no step logs", + Steps: []shared.Step{{ + Name: "fob the barz", + Number: 1, + }}, + }, + wantJobMatch: true, + wantJobFilename: "2_cool job with no step logs.txt", + wantStepMatch: false, + }, + { + name: "matching job name with no step data", + job: shared.Job{ + Name: "cool job with no step logs", + }, + wantJobMatch: true, + wantJobFilename: "2_cool job with no step logs.txt", + wantStepMatch: false, + }, + { + name: "matching job name with legacy filename and no step logs", + job: shared.Job{ + Name: "legacy cool job with no step logs", + Steps: []shared.Step{{ + Name: "fob the barz", + Number: 1, + }}, + }, + wantJobMatch: true, + wantJobFilename: "-9999999999_legacy cool job with no step logs.txt", + wantStepMatch: false, + }, + { + name: "matching job name with legacy filename and no step data", + job: shared.Job{ + Name: "legacy cool job with no step logs", + }, + wantJobMatch: true, + wantJobFilename: "-9999999999_legacy cool job with no step logs.txt", + wantStepMatch: false, }, { name: "one job name is a suffix of another", @@ -1484,8 +1545,8 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, - wantFilename: "ad job/1_barf the quux.txt", + wantStepMatch: true, + wantStepFilename: "ad job/1_barf the quux.txt", }, { name: "escape metacharacters in job name", @@ -1496,7 +1557,8 @@ func Test_attachRunLog(t *testing.T) { Number: 0, }}, }, - wantMatch: false, + wantJobMatch: false, + wantStepMatch: false, }, { name: "mismatching job name", @@ -1507,7 +1569,8 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: false, + wantJobMatch: false, + wantStepMatch: false, }, { name: "job name with forward slash matches dir with slash removed", @@ -1518,9 +1581,10 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, + wantJobMatch: false, + wantStepMatch: true, // not the double space in the dir name, as the slash has been removed - wantFilename: "cool job with slash/1_fob the barz.txt", + wantStepFilename: "cool job with slash/1_fob the barz.txt", }, { name: "job name with colon matches dir with colon removed", @@ -1531,8 +1595,9 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, - wantFilename: "cool job with colon/1_fob the barz.txt", + wantJobMatch: false, + wantStepMatch: true, + wantStepFilename: "cool job with colon/1_fob the barz.txt", }, { name: "Job name with really long name (over the ZIP limit)", @@ -1543,8 +1608,9 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, - wantFilename: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_Long Name Job.txt", + wantJobMatch: false, + wantStepMatch: true, + wantStepFilename: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_Long Name Job.txt", }, { name: "Job name that would be truncated by the C# server to split a grapheme", @@ -1555,8 +1621,9 @@ func Test_attachRunLog(t *testing.T) { Number: 1, }}, }, - wantMatch: true, - wantFilename: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅�/1_Emoji Job.txt", + wantJobMatch: false, + wantStepMatch: true, + wantStepFilename: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅�/1_Emoji Job.txt", }, } @@ -1566,17 +1633,27 @@ func Test_attachRunLog(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + jobs := []shared.Job{tt.job} - attachRunLog(&run_log_zip_reader.Reader, []shared.Job{tt.job}) + attachRunLog(&run_log_zip_reader.Reader, jobs) t.Logf("Job details: ") - for _, step := range tt.job.Steps { - log := step.Log - logPresent := log != nil - require.Equal(t, tt.wantMatch, logPresent, "log not present") - if logPresent { - require.Equal(t, tt.wantFilename, log.Name, "Filename mismatch") + job := jobs[0] + + jobLog := job.Log + jobLogPresent := jobLog != nil + require.Equal(t, tt.wantJobMatch, jobLogPresent, "job log not present") + if jobLogPresent { + require.Equal(t, tt.wantJobFilename, jobLog.Name, "job log filename mismatch") + } + + for _, step := range job.Steps { + stepLog := step.Log + stepLogPresent := stepLog != nil + require.Equal(t, tt.wantStepMatch, stepLogPresent, "step log not present") + if stepLogPresent { + require.Equal(t, tt.wantStepFilename, stepLog.Name, "step log filename mismatch") } } }) From 021537418e577720241cc0ff61473a045125edee Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 7 Apr 2025 13:53:35 +0100 Subject: [PATCH 036/147] Verify fallback to job run logs when step logs are missing Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/shared/test.go | 108 ++++++ pkg/cmd/run/view/view_test.go | 639 ++++++++++++++++++++++++++++++++++ 2 files changed, 747 insertions(+) diff --git a/pkg/cmd/run/shared/test.go b/pkg/cmd/run/shared/test.go index 0619541a4..5a8a4584e 100644 --- a/pkg/cmd/run/shared/test.go +++ b/pkg/cmd/run/shared/test.go @@ -104,6 +104,60 @@ var SuccessfulJob Job = Job{ }, } +// Note that this run *has* steps, but in the ZIP archive the step logs are not +// included. +var SuccessfulJobWithoutStepLogs Job = Job{ + ID: 11, + Status: Completed, + Conclusion: Success, + Name: "cool job with no step logs", + StartedAt: TestRunStartTime, + CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34), + URL: "https://github.com/jobs/11", + RunID: 3, + Steps: []Step{ + { + Name: "fob the barz", + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "barz the fob", + Status: Completed, + Conclusion: Success, + Number: 2, + }, + }, +} + +// Note that this run *has* steps, but in the ZIP archive the step logs are not +// included. +var LegacySuccessfulJobWithoutStepLogs Job = Job{ + ID: 12, + Status: Completed, + Conclusion: Success, + Name: "legacy cool job with no step logs", + StartedAt: TestRunStartTime, + CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34), + URL: "https://github.com/jobs/12", + RunID: 3, + Steps: []Step{ + { + Name: "fob the barz", + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "barz the fob", + Status: Completed, + Conclusion: Success, + Number: 2, + }, + }, +} + var FailedJob Job = Job{ ID: 20, Status: Completed, @@ -129,6 +183,60 @@ var FailedJob Job = Job{ }, } +// Note that this run *has* steps, but in the ZIP archive the step logs are not +// included. +var FailedJobWithoutStepLogs Job = Job{ + ID: 21, + Status: Completed, + Conclusion: Failure, + Name: "sad job with no step logs", + StartedAt: TestRunStartTime, + CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34), + URL: "https://github.com/jobs/21", + RunID: 1234, + Steps: []Step{ + { + Name: "barf the quux", + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "quux the barf", + Status: Completed, + Conclusion: Failure, + Number: 2, + }, + }, +} + +// Note that this run *has* steps, but in the ZIP archive the step logs are not +// included. +var LegacyFailedJobWithoutStepLogs Job = Job{ + ID: 22, + Status: Completed, + Conclusion: Failure, + Name: "legacy sad job with no step logs", + StartedAt: TestRunStartTime, + CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34), + URL: "https://github.com/jobs/22", + RunID: 1234, + Steps: []Step{ + { + Name: "barf the quux", + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "quux the barf", + Status: Completed, + Conclusion: Failure, + Number: 2, + }, + }, +} + var SuccessfulJobAnnotations []Annotation = []Annotation{ { JobName: "cool job", diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index ea0b92f40..b5d619cd6 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -990,6 +990,619 @@ func TestViewRun(t *testing.T) { }, wantOut: quuxTheBarfLogOutput, }, + { + name: "interactive with log, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJobWithoutStepLogs, + shared.FailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021") + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool job with no step logs") + }) + }, + wantOut: coolJobRunWithNoStepLogsLogOutput, + }, + { + name: "noninteractive with log, with no step logs available (#10551)", + opts: &ViewOptions{ + JobID: "11", + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/11"), + httpmock.JSONResponse(shared.SuccessfulJobWithoutStepLogs)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: coolJobRunWithNoStepLogsLogOutput, + }, + { + name: "interactive with log-failed, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJobWithoutStepLogs, + shared.FailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return 4, nil + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "X sad job with no step logs") + }) + }, + wantOut: sadJobRunWithNoStepLogsLogOutput, + }, + { + name: "noninteractive with log-failed, with no step logs available (#10551)", + opts: &ViewOptions{ + JobID: "21", + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/21"), + httpmock.JSONResponse(shared.FailedJobWithoutStepLogs)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: sadJobRunWithNoStepLogsLogOutput, + }, + { + name: "interactive with run log, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJobWithoutStepLogs, + shared.FailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021") + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "View all jobs in this run") + }) + }, + wantOut: expectedRunLogOutputWithNoSteps, + }, + { + name: "noninteractive with run log, with no step logs available (#10551)", + opts: &ViewOptions{ + RunID: "3", + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJobWithoutStepLogs, + shared.FailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: expectedRunLogOutputWithNoSteps, + }, + { + name: "interactive with run log-failed, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJobWithoutStepLogs, + shared.FailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return 4, nil + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "View all jobs in this run") + }) + }, + wantOut: sadJobRunWithNoStepLogsLogOutput, + }, + { + name: "noninteractive with run log-failed, with no step logs available (#10551)", + opts: &ViewOptions{ + RunID: "1234", + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.SuccessfulJobWithoutStepLogs, + shared.FailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: sadJobRunWithNoStepLogsLogOutput, + }, + { + name: "interactive with log, legacy service data, with no step logs available", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.LegacySuccessfulJobWithoutStepLogs, + shared.LegacyFailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021") + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ legacy cool job with no step logs") + }) + }, + wantOut: legacyCoolJobRunWithNoStepLogsLogOutput, + }, + { + name: "noninteractive with log, legacy service data, with no step logs available", + opts: &ViewOptions{ + JobID: "12", + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/12"), + httpmock.JSONResponse(shared.LegacySuccessfulJobWithoutStepLogs)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: legacyCoolJobRunWithNoStepLogsLogOutput, + }, + + { + name: "interactive with log-failed, legacy service data, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.LegacySuccessfulJobWithoutStepLogs, + shared.LegacyFailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return 4, nil + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "X legacy sad job with no step logs") + }) + }, + wantOut: legacySadJobRunWithNoStepLogsLogOutput, + }, + { + name: "noninteractive with log-failed, legacy service data, with no step logs available (#10551)", + opts: &ViewOptions{ + JobID: "22", + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/22"), + httpmock.JSONResponse(shared.LegacyFailedJobWithoutStepLogs)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: legacySadJobRunWithNoStepLogsLogOutput, + }, + { + name: "interactive with run log, legacy service data, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.LegacySuccessfulJobWithoutStepLogs, + shared.LegacyFailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021") + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "View all jobs in this run") + }) + }, + wantOut: expectedLegacyRunLogOutputWithNoSteps, + }, + { + name: "noninteractive with run log, legacy service data, with no step logs available (#10551)", + opts: &ViewOptions{ + RunID: "3", + Log: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"), + httpmock.JSONResponse(shared.SuccessfulRun)) + reg.Register( + httpmock.REST("GET", "runs/3/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.LegacySuccessfulJobWithoutStepLogs, + shared.LegacyFailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: expectedLegacyRunLogOutputWithNoSteps, + }, + { + name: "interactive with run log-failed, legacy service data, with no step logs available (#10551)", + tty: true, + opts: &ViewOptions{ + Prompt: true, + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"), + httpmock.JSONResponse(shared.RunsPayload{ + WorkflowRuns: shared.TestRuns, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.LegacySuccessfulJobWithoutStepLogs, + shared.LegacyFailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + }, + promptStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a workflow run", + []string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"}, + func(_, _ string, opts []string) (int, error) { + return 4, nil + }) + pm.RegisterSelect("View a specific job in this run?", + []string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, "View all jobs in this run") + }) + }, + wantOut: legacySadJobRunWithNoStepLogsLogOutput, + }, + { + name: "noninteractive with run log-failed, legacy service data, with no step logs available (#10551)", + opts: &ViewOptions{ + RunID: "1234", + LogFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("GET", "runs/1234/jobs"), + httpmock.JSONResponse(shared.JobsPayload{ + Jobs: []shared.Job{ + shared.LegacySuccessfulJobWithoutStepLogs, + shared.LegacyFailedJobWithoutStepLogs, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"), + httpmock.FileResponse("./fixtures/run_log.zip")) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(shared.TestWorkflow)) + }, + wantOut: legacySadJobRunWithNoStepLogsLogOutput, + }, { name: "run log but run is not done", tty: true, @@ -1684,9 +2297,35 @@ sad job quux the barf log line 2 sad job quux the barf log line 3 `) +var coolJobRunWithNoStepLogsLogOutput = heredoc.Doc(` +cool job with no step logs UNKNOWN log line 1 +cool job with no step logs UNKNOWN log line 2 +cool job with no step logs UNKNOWN log line 3 +`) + +var legacyCoolJobRunWithNoStepLogsLogOutput = heredoc.Doc(` +legacy cool job with no step logs UNKNOWN log line 1 +legacy cool job with no step logs UNKNOWN log line 2 +legacy cool job with no step logs UNKNOWN log line 3 +`) + +var sadJobRunWithNoStepLogsLogOutput = heredoc.Doc(` +sad job with no step logs UNKNOWN log line 1 +sad job with no step logs UNKNOWN log line 2 +sad job with no step logs UNKNOWN log line 3 +`) + +var legacySadJobRunWithNoStepLogsLogOutput = heredoc.Doc(` +legacy sad job with no step logs UNKNOWN log line 1 +legacy sad job with no step logs UNKNOWN log line 2 +legacy sad job with no step logs UNKNOWN log line 3 +`) + var coolJobRunLogOutput = fmt.Sprintf("%s%s", fobTheBarzLogOutput, barfTheFobLogOutput) var sadJobRunLogOutput = fmt.Sprintf("%s%s", barfTheQuuxLogOutput, quuxTheBarfLogOutput) var expectedRunLogOutput = fmt.Sprintf("%s%s", coolJobRunLogOutput, sadJobRunLogOutput) +var expectedRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", coolJobRunWithNoStepLogsLogOutput, sadJobRunWithNoStepLogsLogOutput) +var expectedLegacyRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", legacyCoolJobRunWithNoStepLogsLogOutput, legacySadJobRunWithNoStepLogsLogOutput) func TestRunLog(t *testing.T) { t.Run("when the cache dir doesn't exist, exists return false", func(t *testing.T) { From fc84b7a3cbf0d17d2d16e666aba5899d88340156 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 7 Apr 2025 15:21:48 +0100 Subject: [PATCH 037/147] Escape dots in regexp pattern in example command Signed-off-by: Babak K. Shandiz --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fbd99a5a8..ad012588c 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ There are two common ways to verify a downloaded release, depending if `gh` is a $ cosign verify-blob-attestation --bundle cli-cli-attestation-3120304.sigstore.json \ --new-bundle-format \ --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ - --certificate-identity-regexp="^https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk$" \ + --certificate-identity-regexp='^https://github\.com/cli/cli/\.github/workflows/deployment\.yml@refs/heads/trunk$' \ gh_2.62.0_macOS_arm64.zip Verified OK ``` From 5996f882fc9561b40d99d27cb5184f05ae72ced0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 7 Apr 2025 10:40:29 -0600 Subject: [PATCH 038/147] doc(envs): speech synthesis prompter --- pkg/cmd/root/help_topic.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index db0ef098d..e0a1a8535 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -108,6 +108,9 @@ var HelpTopics = []helpTopic{ %[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 not specified. This value is used, for example, with %[1]spr view%[1]s subcommand. + + %[1]sGH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER%[1]s: set to a truthy value to enable prompts that are + more compatible with speech synthesis based screen readers. `, "`"), }, { From 2a851e33e89509e37c6fba810944b11dd30113ff Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 7 Apr 2025 11:59:05 -0600 Subject: [PATCH 039/147] test(prompter): fix race conditions --- ...eech_synthesizer_friendly_prompter_test.go | 140 +++++++++++++----- 1 file changed, 107 insertions(+), 33 deletions(-) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 4091db33a..e845febad 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -7,6 +7,7 @@ import ( "io" "os" "strings" + "sync" "testing" "time" @@ -56,10 +57,17 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { os.Stderr = console.Tty() t.Setenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER", "true") - p := prompter.New("", nil, nil, nil) + // Using echo as the editor command here because it will immediately exit + // and return no input. + p := prompter.New("echo", nil, nil, nil) + + var wg sync.WaitGroup t.Run("Select", func(t *testing.T) { + wg.Add(1) + go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Choose:") require.NoError(t, err) @@ -71,12 +79,16 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) require.NoError(t, err) - assert.Equal(t, 0, selectValue) + + wg.Wait() }) t.Run("MultiSelect", func(t *testing.T) { + wg.Add(1) + go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Select a number") require.NoError(t, err) @@ -94,13 +106,17 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"}) require.NoError(t, err) - assert.Equal(t, []int{0, 1}, multiSelectValue) + + wg.Wait() }) t.Run("Input", func(t *testing.T) { + wg.Add(1) + dummyText := "12345abcdefg" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Enter some characters") require.NoError(t, err) @@ -112,13 +128,17 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { inputValue, err := p.Input("Enter some characters", "") require.NoError(t, err) - assert.Equal(t, dummyText, inputValue) + + wg.Wait() }) t.Run("Input - blank input returns default value", func(t *testing.T) { + wg.Add(1) + dummyDefaultValue := "12345abcdefg" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Enter some characters") require.NoError(t, err) @@ -134,13 +154,17 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { inputValue, err := p.Input("Enter some characters", dummyDefaultValue) require.NoError(t, err) - assert.Equal(t, dummyDefaultValue, inputValue) + + wg.Wait() }) t.Run("Password", func(t *testing.T) { + wg.Add(1) + dummyPassword := "12345abcdefg" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Enter password") require.NoError(t, err) @@ -153,10 +177,15 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { passwordValue, err := p.Password("Enter password") require.NoError(t, err) require.Equal(t, dummyPassword, passwordValue) + + wg.Wait() }) t.Run("Confirm", func(t *testing.T) { + wg.Add(1) + go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Are you sure") require.NoError(t, err) @@ -169,30 +198,16 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { confirmValue, err := p.Confirm("Are you sure", false) require.NoError(t, err) require.Equal(t, true, confirmValue) + + wg.Wait() }) - // This test currently fails because the value is - // not respected as the default in accessible mode. - // See https://github.com/charmbracelet/huh/issues/615 - // t.Run("Confirm - blank input returns default", func(t *testing.T) { - // go func() { - // // Wait for prompt to appear - // _, err := console.ExpectString("Are you sure") - // require.NoError(t, err) - - // // Enter nothing - // _, err = console.SendLine("") - // require.NoError(t, err) - // }() - - // confirmValue, err := p.Confirm("Are you sure", false) - // require.NoError(t, err) - // require.Equal(t, false, confirmValue) - // }) - t.Run("AuthToken", func(t *testing.T) { + wg.Add(1) + dummyAuthToken := "12345abcdefg" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Paste your authentication token:") require.NoError(t, err) @@ -205,11 +220,16 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { authValue, err := p.AuthToken() require.NoError(t, err) require.Equal(t, dummyAuthToken, authValue) + + wg.Wait() }) t.Run("AuthToken - blank input returns error", func(t *testing.T) { + wg.Add(1) + dummyAuthTokenForAfterFailure := "12345abcdefg" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Paste your authentication token:") require.NoError(t, err) @@ -230,11 +250,16 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { authValue, err := p.AuthToken() require.NoError(t, err) require.Equal(t, dummyAuthTokenForAfterFailure, authValue) + + wg.Wait() }) t.Run("ConfirmDeletion", func(t *testing.T) { + wg.Add(1) + requiredValue := "test" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) require.NoError(t, err) @@ -247,12 +272,17 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { // An err indicates that the confirmation text sent did not match err := p.ConfirmDeletion(requiredValue) require.NoError(t, err) + + wg.Wait() }) t.Run("ConfirmDeletion - bad input", func(t *testing.T) { + wg.Add(1) + requiredValue := "test" badInputValue := "garbage" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) require.NoError(t, err) @@ -273,11 +303,16 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { // An err indicates that the confirmation text sent did not match err := p.ConfirmDeletion(requiredValue) require.NoError(t, err) + + wg.Wait() }) t.Run("InputHostname", func(t *testing.T) { + wg.Add(1) + hostname := "somethingdoesnotmatter.com" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Hostname:") require.NoError(t, err) @@ -290,10 +325,15 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { inputValue, err := p.InputHostname() require.NoError(t, err) require.Equal(t, hostname, inputValue) + + wg.Wait() }) - t.Run("MarkdownEditor - blank allowed", func(t *testing.T) { + t.Run("MarkdownEditor - blank allowed with blank input returns blank", func(t *testing.T) { + wg.Add(1) + go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("How to edit?") require.NoError(t, err) @@ -306,15 +346,21 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { inputValue, err := p.MarkdownEditor("How to edit?", "", true) require.NoError(t, err) require.Equal(t, "", inputValue) + + wg.Wait() }) - t.Run("MarkdownEditor - blank disallowed", func(t *testing.T) { + t.Run("MarkdownEditor - blank disallowed with default value returns default value", func(t *testing.T) { + wg.Add(1) + + defaultValue := "12345abcdefg" go func() { + defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("How to edit?") require.NoError(t, err) - // Enter number 2 to select "skip". This shoudln't be allowed. + // Enter number 2 to select "skip". This shouldn't be allowed. _, err = console.SendLine("2") require.NoError(t, err) @@ -322,18 +368,46 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { _, err = console.ExpectString("invalid input. please try again") require.NoError(t, err) - // Send a 1 to select to open the editor. - // Sending the input won't fail, so we expect no error here. - // See below though, since we expect the editor to fail to open. + // Send a 1 to select to open the editor. This will immediately exit + _, err = console.SendLine("1") + require.NoError(t, err) + }() + + inputValue, err := p.MarkdownEditor("How to edit?", defaultValue, false) + require.NoError(t, err) + require.Equal(t, defaultValue, inputValue) + + wg.Wait() + }) + + t.Run("MarkdownEditor - blank disallowed no default value returns error", func(t *testing.T) { + wg.Add(1) + + go func() { + defer wg.Done() + // Wait for prompt to appear + _, err := console.ExpectString("How to edit?") + require.NoError(t, err) + + // Enter number 2 to select "skip". This shouldn't be allowed. + _, err = console.SendLine("2") + require.NoError(t, err) + + // Expect a notice to enter something valid since blank is disallowed. + _, err = console.ExpectString("invalid input. please try again") + require.NoError(t, err) + + // Send a 1 to select to open the editor since skip is invalid and + // we need to return control back to the test. _, err = console.SendLine("1") require.NoError(t, err) }() - // However, here we do expect an error because the editor program - // is intentionally empty and will fail. inputValue, err := p.MarkdownEditor("How to edit?", "", false) - require.Error(t, err) + require.NoError(t, err) require.Equal(t, "", inputValue) + + wg.Wait() }) } From 0543aac53c564fb7ead6c44f741954594dd85494 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 7 Apr 2025 12:54:03 -0600 Subject: [PATCH 040/147] test(prompter): add basic survey prompter test --- ...eech_synthesizer_friendly_prompter_test.go | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index e845febad..6970e3d33 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -411,6 +411,63 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { }) } +func TestSurveyPrompter(t *testing.T) { + // Create a PTY and hook up a virtual terminal emulator + ptm, pts, err := pty.Open() + require.NoError(t, err) + + term := vt10x.New(vt10x.WithWriter(pts)) + + // Create a console via Expect that allows scripting against the terminal + consoleOpts := []expect.ConsoleOpt{ + expect.WithStdin(ptm), + expect.WithStdout(term), + expect.WithCloser(ptm, pts), + failOnExpectError(t), + failOnSendError(t), + expect.WithDefaultTimeout(time.Second * 600), + } + + console, err := expect.NewConsole(consoleOpts...) + require.NoError(t, err) + t.Cleanup(func() { testCloser(t, console) }) + + t.Setenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER", "") + t.Setenv("NO_COLOR", "1") + // Using echo as the editor command here because it will immediately exit + // and return no input. + p := prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) + + var wg sync.WaitGroup + + // This not a comprehensive test of the survey prompter, but it does + // demonstrate that the survey prompter is used when the speech + // synthesizer friendly prompter is disabled. + t.Run("Select uses survey prompter when speech synthesizer friendly prompter is disabled", func(t *testing.T) { + wg.Add(1) + + go func() { + defer wg.Done() + // Wait for prompt to appear + _, err := console.ExpectString("Select a number") + require.NoError(t, err) + + // Send a newline to select the first option + // Note: This would not work with the speech synthesizer friendly prompter + // because it would requires sending a 1 to select the first option. + // So it proves we are seeing a survey prompter. + _, err = console.SendLine("") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) + require.NoError(t, err) + assert.Equal(t, 0, selectValue) + + wg.Wait() + }) +} + // failOnExpectError adds an observer that will fail the test in a standardised way // if any expectation on the command output fails, without requiring an explicit // assertion. From 66407402c0fa7df7f8789642e0b76d1772d96c89 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:53:31 -0600 Subject: [PATCH 041/147] doc: comment typos and formatting Co-authored-by: Andy Feller --- internal/prompter/prompter.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 0a42a8df9..913b1a5e7 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -144,13 +144,12 @@ func (p *speechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) ( func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, error) { var result string + // EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode. form := p.newForm( huh.NewGroup( huh.NewInput(). Title(prompt). Value(&result), - // This doesn't have any effect in accessible mode. - // EchoMode(huh.EchoModePassword), ), ) @@ -159,7 +158,7 @@ func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, err } func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { - // This is currently an inneffectual assignment because the value is + // This is currently an ineffectual assignment because the value is // not respected as the default in accessible mode. Leaving this in here // because it may change in the future. // See https://github.com/charmbracelet/huh/issues/615 @@ -202,6 +201,7 @@ func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { var result string + // EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode. form := p.newForm( huh.NewGroup( huh.NewInput(). @@ -213,8 +213,6 @@ func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string return nil }). Value(&result), - // This doesn't have any effect in accessible mode. - // EchoMode(huh.EchoModePassword), ), ) From c5ffb3cbfeec54c3882f589457ac2af561c551af Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:54:41 -0600 Subject: [PATCH 042/147] test: use example.com in tests --- internal/prompter/speech_synthesizer_friendly_prompter_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 6970e3d33..df8d2f445 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -310,7 +310,7 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { t.Run("InputHostname", func(t *testing.T) { wg.Add(1) - hostname := "somethingdoesnotmatter.com" + hostname := "example.com" go func() { defer wg.Done() // Wait for prompt to appear From fb80b5bd86cde6a009071b0b2e6d173e89abe3a9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:56:33 -0600 Subject: [PATCH 043/147] test(prompter): remove needless NO_COLOR set --- internal/prompter/speech_synthesizer_friendly_prompter_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index df8d2f445..74b053072 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -433,7 +433,6 @@ func TestSurveyPrompter(t *testing.T) { t.Cleanup(func() { testCloser(t, console) }) t.Setenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER", "") - t.Setenv("NO_COLOR", "1") // Using echo as the editor command here because it will immediately exit // and return no input. p := prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) From d8d3874778038fb5de581b7ed93b4e9bbaa4f185 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:38:54 -0600 Subject: [PATCH 044/147] fix(prompter): use os.lookupenv for accessible prompter --- internal/prompter/prompter.go | 24 ++++++++++--------- ...eech_synthesizer_friendly_prompter_test.go | 1 - 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 913b1a5e7..2c027c184 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -3,6 +3,7 @@ package prompter import ( "fmt" "os" + "slices" "strings" "github.com/AlecAivazis/survey/v2" @@ -43,17 +44,10 @@ type Prompter interface { } func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter { - accessiblePrompterValue := os.Getenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER") - switch accessiblePrompterValue { - case "", "false", "0", "no": - return &surveyPrompter{ - prompter: ghPrompter.New(stdin, stdout, stderr), - stdin: stdin, - stdout: stdout, - stderr: stderr, - editorCmd: editorCmd, - } - default: + accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER") + falseyValues := []string{"false", "0", "no", ""} + + if accessiblePrompterIsSet && !slices.Contains(falseyValues, accessiblePrompterValue) { return &speechSynthesizerFriendlyPrompter{ stdin: stdin, stdout: stdout, @@ -61,6 +55,14 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr editorCmd: editorCmd, } } + + return &surveyPrompter{ + prompter: ghPrompter.New(stdin, stdout, stderr), + stdin: stdin, + stdout: stdout, + stderr: stderr, + editorCmd: editorCmd, + } } type speechSynthesizerFriendlyPrompter struct { diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 74b053072..64e9ac2fe 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -432,7 +432,6 @@ func TestSurveyPrompter(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { testCloser(t, console) }) - t.Setenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER", "") // Using echo as the editor command here because it will immediately exit // and return no input. p := prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) From ef58e627f9d2cd3698bba857d6eca1450278dbb5 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:44:14 -0600 Subject: [PATCH 045/147] test(prompter): timeout for tests is 1s --- internal/prompter/speech_synthesizer_friendly_prompter_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 64e9ac2fe..d461bf1e4 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -33,7 +33,7 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { expect.WithCloser(ptm, pts), failOnExpectError(t), failOnSendError(t), - expect.WithDefaultTimeout(time.Second * 600), + expect.WithDefaultTimeout(time.Second), } console, err := expect.NewConsole(consoleOpts...) From c4be95afd962945d123a760101e6d8b26aecd7c3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 11:58:50 -0600 Subject: [PATCH 046/147] refactor(prompter): remove unused variable --- internal/prompter/prompter.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 2c027c184..2595463e0 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -202,7 +202,6 @@ func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { } func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { - var result string // EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode. form := p.newForm( huh.NewGroup( @@ -213,8 +212,7 @@ func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string return fmt.Errorf("You entered: %q", input) } return nil - }). - Value(&result), + }), ), ) From 8821f77fbbe97bf3f16ad70a7ef6e1471a948da0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:00:02 -0600 Subject: [PATCH 047/147] doc(prompter): remove senseless comment --- internal/prompter/prompter.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 2595463e0..f1900c751 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -202,7 +202,6 @@ func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { } func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { - // EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode. form := p.newForm( huh.NewGroup( huh.NewInput(). From 9cf341302eac404813eef1de4e45d8605a8985e0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:03:55 -0600 Subject: [PATCH 048/147] refactor(prompter): explicit return values --- internal/prompter/prompter.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index f1900c751..556fa6f4c 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -156,7 +156,11 @@ func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, err ) err := form.Run() - return result, err + if err != nil { + return "", err + } + + return result, nil } func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { @@ -230,7 +234,10 @@ func (p *speechSynthesizerFriendlyPrompter) InputHostname() (string, error) { ) err := form.Run() - return result, err + if err != nil { + return "", err + } + return result, nil } func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { From 19387b84187bef5edf437f4a46399843979e955e Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 12:52:39 -0600 Subject: [PATCH 049/147] fix(prompter): rename GH_ACCESSIBLE_PROMPTER --- internal/prompter/prompter.go | 2 +- internal/prompter/speech_synthesizer_friendly_prompter_test.go | 2 +- pkg/cmd/root/help_topic.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 556fa6f4c..bf5655846 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -44,7 +44,7 @@ type Prompter interface { } func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter { - accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER") + accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER") falseyValues := []string{"false", "0", "no", ""} if accessiblePrompterIsSet && !slices.Contains(falseyValues, accessiblePrompterValue) { diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index d461bf1e4..20935a566 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -56,7 +56,7 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { os.Stdout = console.Tty() os.Stderr = console.Tty() - t.Setenv("GH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER", "true") + t.Setenv("GH_ACCESSIBLE_PROMPTER", "true") // Using echo as the editor command here because it will immediately exit // and return no input. p := prompter.New("echo", nil, nil, nil) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index e0a1a8535..f7a827dcd 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -109,7 +109,7 @@ var HelpTopics = []helpTopic{ wrapped on the terminal will be taken as the lesser of the terminal width, this value, or 120 if not specified. This value is used, for example, with %[1]spr view%[1]s subcommand. - %[1]sGH_SPEECH_SYNTHESIZER_FRIENDLY_PROMPTER%[1]s: set to a truthy value to enable prompts that are + %[1]sGH_ACCESSIBLE_PROMPTER%[1]s: set to a truthy value to enable prompts that are more compatible with speech synthesis based screen readers. `, "`"), }, From fa03157bebce3d0578d934a81e67d9b9acf7188d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:04:04 -0600 Subject: [PATCH 050/147] doc(help): label GH_ACCESSIBLE_PROMPTER as preview --- pkg/cmd/root/help_topic.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index f7a827dcd..1a0934bb5 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -109,7 +109,7 @@ var HelpTopics = []helpTopic{ wrapped on the terminal will be taken as the lesser of the terminal width, this value, or 120 if not specified. This value is used, for example, with %[1]spr view%[1]s subcommand. - %[1]sGH_ACCESSIBLE_PROMPTER%[1]s: set to a truthy value to enable prompts that are + %[1]sGH_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are more compatible with speech synthesis based screen readers. `, "`"), }, From d230b08c4367266c60a3c3618b208d234d0a9ffb Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:09:15 -0600 Subject: [PATCH 051/147] test(prompter): re-add skipped test for accessible confirm default --- ...eech_synthesizer_friendly_prompter_test.go | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/speech_synthesizer_friendly_prompter_test.go index 20935a566..548825707 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/speech_synthesizer_friendly_prompter_test.go @@ -202,6 +202,26 @@ func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { wg.Wait() }) + // This test currently fails because the value is + // not respected as the default in accessible mode. + // See https://github.com/charmbracelet/huh/issues/615 + t.Run("Confirm - blank input returns default", func(t *testing.T) { + t.Skip("Skipped due to https://github.com/charmbracelet/huh/issues/615") + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Are you sure") + require.NoError(t, err) + + // Enter nothing + _, err = console.SendLine("") + require.NoError(t, err) + }() + + confirmValue, err := p.Confirm("Are you sure", false) + require.NoError(t, err) + require.Equal(t, false, confirmValue) + }) + t.Run("AuthToken", func(t *testing.T) { wg.Add(1) From c5206109ca9db2d2215b1de68c5ccc0cb9bfa548 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Tue, 8 Apr 2025 15:24:18 -0400 Subject: [PATCH 052/147] Use truthy value for `GH_COLOR_LABELS` --- pkg/cmd/factory/default.go | 9 +++++++-- pkg/cmd/factory/default_test.go | 32 +++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 68df7a316..6286c999d 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -293,8 +293,13 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams { io.SetPager(pager.Value) } - if _, ghColorLabels := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabels { - io.SetColorLabels(true) // TODO: should this be a truthy value? + if ghColorLabels, ghColorLabelsExists := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabelsExists { + switch ghColorLabels { + case "", "0", "false", "no": + io.SetColorLabels(false) + default: + io.SetColorLabels(true) + } } else if prompt := cfg.ColorLabels(""); prompt.Value == "enabled" { io.SetColorLabels(true) } diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index b1730d6e6..407c2fcdb 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -449,10 +449,40 @@ func Test_ioStreams_colorLabels(t *testing.T) { colorLabelsEnabled: true, }, { - name: "colorLabels enabled via GH_COLOR_LABELS env var", + name: "colorLabels enabled via `1` in GH_COLOR_LABELS env var", env: map[string]string{"GH_COLOR_LABELS": "1"}, colorLabelsEnabled: true, }, + { + name: "colorLabels enabled via `true` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "true"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels enabled via `yes` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "yes"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels disable via empty string in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": ""}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `0` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "0"}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `false` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "false"}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `no` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "no"}, + colorLabelsEnabled: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 5c12f5633e3beee887be089a82b0ec52469ad087 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Tue, 8 Apr 2025 15:27:36 -0400 Subject: [PATCH 053/147] Test for explicit config disable of label colors --- pkg/cmd/factory/default_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index 407c2fcdb..c0275d1de 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -448,6 +448,11 @@ func Test_ioStreams_colorLabels(t *testing.T) { config: enableColorLabelsConfig(), colorLabelsEnabled: true, }, + { + name: "config with colorLabels disabled", + config: disableColorLabelsConfig(), + colorLabelsEnabled: false, + }, { name: "colorLabels enabled via `1` in GH_COLOR_LABELS env var", env: map[string]string{"GH_COLOR_LABELS": "1"}, @@ -611,6 +616,10 @@ func disablePromptConfig() gh.Config { return config.NewFromString("prompt: disabled") } +func disableColorLabelsConfig() gh.Config { + return config.NewFromString("color_labels: disabled") +} + func enableColorLabelsConfig() gh.Config { return config.NewFromString("color_labels: enabled") } From 644dbe6275861cec50b744330576f97b78f978b5 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Tue, 8 Apr 2025 15:56:23 -0400 Subject: [PATCH 054/147] Apply suggestions from code review Co-authored-by: Babak K. Shandiz --- pkg/cmd/pr/status/status.go | 2 +- pkg/cmd/release/view/view.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index d20522d04..eb120e5a7 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -316,6 +316,6 @@ func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) { } remaining := totalCount - len(prs) if remaining > 0 { - fmt.Fprintf(w, cs.Muted(" And %d more\n"), remaining) + fmt.Fprintln(w, cs.Mutedf(" And %d more", remaining)) } } diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index db0e6ae1d..c9030f299 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -139,9 +139,9 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { fmt.Fprintf(w, "%s • ", cs.Yellow("Pre-release")) } if release.IsDraft { - fmt.Fprintf(w, "%s\n", cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))) + fmt.Fprintln(w, cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))) } else { - fmt.Fprintf(w, "%s\n", cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))) + fmt.Fprintln(w, cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))) } renderedDescription, err := markdown.Render(release.Body, @@ -153,7 +153,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { fmt.Fprintln(w, renderedDescription) if len(release.Assets) > 0 { - fmt.Fprintf(w, "%s\n", cs.Bold("Assets")) + fmt.Fprintln(w, cs.Bold("Assets")) //nolint:staticcheck // SA1019: Showing NAME|SIZE headers adds nothing to table. table := tableprinter.New(io, tableprinter.NoHeader) for _, a := range release.Assets { From 93e51c583b7f1cfa85c096f83d3abc7589836076 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Tue, 8 Apr 2025 16:40:39 -0400 Subject: [PATCH 055/147] Update pkg/cmd/run/shared/presentation.go --- pkg/cmd/run/shared/presentation.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/run/shared/presentation.go b/pkg/cmd/run/shared/presentation.go index 699ea120f..a3556d743 100644 --- a/pkg/cmd/run/shared/presentation.go +++ b/pkg/cmd/run/shared/presentation.go @@ -52,6 +52,7 @@ func RenderAnnotations(cs *iostreams.ColorScheme, annotations []Annotation) stri for _, a := range annotations { lines = append(lines, fmt.Sprintf("%s %s", AnnotationSymbol(cs, a), a.Message)) + // Following newline is essential for spacing between annotations lines = append(lines, cs.Mutedf("%s: %s#%d\n", a.JobName, a.Path, a.StartLine)) } From 261cea20754c79cbd76761c2cbff73af0a002918 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 8 Apr 2025 14:41:18 -0600 Subject: [PATCH 056/147] update error message Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verification/sigstore.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 6dd31dac0..912e21601 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -177,7 +177,7 @@ func (v *LiveSigstoreVerifier) verify(attestation *api.Attestation, policy verif // determine which verifier should attempt verification against the bundle verifier, err := v.chooseVerifier(issuer) if err != nil { - return nil, fmt.Errorf("failed to find recognized issuer from bundle content: %v", err) + return nil, fmt.Errorf("failed to choose verifier based on provided bundle issuer: %v", err) } v.Logger.VerbosePrintf("Attempting verification against issuer \"%s\"\n", issuer) From 68b1d8bf2950506b83c9e8f13a6a17770183cddf Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 8 Apr 2025 15:20:42 -0600 Subject: [PATCH 057/147] update sigstore-go Signed-off-by: Meredith Lancaster --- go.mod | 65 ++++++------ go.sum | 326 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 189 insertions(+), 202 deletions(-) diff --git a/go.mod b/go.mod index bea712a2d..c24bd57cc 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/gabriel-vasile/mimetype v1.4.8 github.com/gdamore/tcell/v2 v2.5.4 github.com/golang/snappy v0.0.4 - github.com/google/go-cmp v0.6.0 + github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.20.3 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/websocket v1.5.3 @@ -40,18 +40,18 @@ require ( github.com/opentracing/opentracing-go v1.2.0 github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc - github.com/sigstore/protobuf-specs v0.3.3 - github.com/sigstore/sigstore-go v0.7.0 - github.com/spf13/cobra v1.8.1 + github.com/sigstore/protobuf-specs v0.4.1 + github.com/sigstore/sigstore-go v0.7.1 + github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 github.com/zalando/go-keyring v0.2.5 - golang.org/x/crypto v0.35.0 - golang.org/x/sync v0.12.0 - golang.org/x/term v0.30.0 - golang.org/x/text v0.23.0 - google.golang.org/grpc v1.69.4 - google.golang.org/protobuf v1.36.5 + golang.org/x/crypto v0.37.0 + golang.org/x/sync v0.13.0 + golang.org/x/term v0.31.0 + golang.org/x/text v0.24.0 + google.golang.org/grpc v1.71.0 + google.golang.org/protobuf v1.36.6 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -83,29 +83,29 @@ require ( github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.8.2 // indirect github.com/fatih/color v1.16.0 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gdamore/encoding v1.0.0 // indirect github.com/go-chi/chi v4.1.2+incompatible // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.23.0 // indirect - github.com/go-openapi/errors v0.22.0 // indirect + github.com/go-openapi/errors v0.22.1 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/loads v0.22.0 // indirect github.com/go-openapi/runtime v0.28.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/strfmt v0.23.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/validate v0.24.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/certificate-transparency-go v1.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/in-toto/in-toto-golang v0.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -116,8 +116,7 @@ require ( github.com/klauspost/compress v1.17.11 // indirect github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect @@ -129,27 +128,26 @@ require ( github.com/oklog/ulid v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rodaine/table v1.0.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sassoftware/relic v7.2.1+incompatible // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect - github.com/sigstore/rekor v1.3.8 // indirect - github.com/sigstore/sigstore v1.8.12 // indirect - github.com/sigstore/timestamp-authority v1.2.4 // indirect + github.com/sigstore/rekor v1.3.9 // indirect + github.com/sigstore/sigstore v1.9.1 // indirect + github.com/sigstore/timestamp-authority v1.2.5 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.7.0 // indirect - github.com/spf13/viper v1.19.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/viper v1.20.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/theupdateframework/go-tuf v0.7.0 // indirect @@ -163,18 +161,17 @@ require ( github.com/yuin/goldmark-emoji v1.0.5 // indirect go.mongodb.org/mongo-driver v1.14.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.33.0 // indirect - go.opentelemetry.io/otel/metric v1.33.0 // indirect - go.opentelemetry.io/otel/trace v1.33.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect - golang.org/x/mod v0.22.0 // indirect - golang.org/x/net v0.36.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.32.0 // indirect golang.org/x/tools v0.29.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect k8s.io/klog/v2 v2.130.1 // indirect ) diff --git a/go.sum b/go.sum index 2b5a31212..a0f64b334 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,17 @@ -cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= -cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= -cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs= -cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q= -cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU= -cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8= -cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= +cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME= +cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc= +cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= +cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= +cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= +cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= -cloud.google.com/go/iam v1.2.2 h1:ozUSofHUGf/F4tCNy/mu9tHLTaxZFLOUiKzjcgWHGIA= -cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY= -cloud.google.com/go/kms v1.20.4 h1:CJ0hMpOg1ANN9tx/a/GPJ+Uxudy8k6f3fvGFuTHiE5A= -cloud.google.com/go/kms v1.20.4/go.mod h1:gPLsp1r4FblUgBYPOcvI/bUPpdMg2Jm1ZVKU4tQUfcc= -cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0MK+hc= -cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI= +cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM= +cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM= +cloud.google.com/go/kms v1.21.1 h1:r1Auo+jlfJSf8B7mUnVw5K0fI7jWyoUy65bV53VjKyk= +cloud.google.com/go/kms v1.21.1/go.mod h1:s0wCyByc9LjTdCjG88toVs70U9W+cc6RKFc8zAqX7nE= +cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q= +cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= @@ -21,18 +20,18 @@ github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjq github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 h1:B/dfvscEQtew9dVuoxqxrUKKv8Ih2f55PydknDamU+g= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0/go.mod h1:fiPSssYvltE08HJchL04dOy+RD4hgrjph0cwGGMntdI= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0 h1:7rKG7UmnrxX4N53TFhkYqjc+kVUZuw0fL8I3Fh+Ld9E= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0/go.mod h1:Wjo+24QJVhhl/L7jy6w9yzFF2yDOf3cKECAa8ecf9vE= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 h1:eXnN9kaS8TiDwXjoie3hMRLuwdUBUMW9KRgOqB3mCaw= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0/go.mod h1:XIpam8wumeZ5rVMuhdDQLMfIPDf1WO3IzrCRO3e3e3o= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1 h1:gUDtaZk8heteyfdmv+pcfHvhR9llnh7c7GMwZ8RVG04= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= +github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4= +github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -53,36 +52,36 @@ github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4u github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= -github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.32.8 h1:cZV+NUS/eGxKXMtmyhtYPJ7Z4YLoI/V8bkTdRZfYhGo= -github.com/aws/aws-sdk-go-v2 v1.32.8/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= -github.com/aws/aws-sdk-go-v2/config v1.28.10 h1:fKODZHfqQu06pCzR69KJ3GuttraRJkhlC8g80RZ0Dfg= -github.com/aws/aws-sdk-go-v2/config v1.28.10/go.mod h1:PvdxRYZ5Um9QMq9PQ0zHHNdtKK+he2NHtFCUFMXWXeg= -github.com/aws/aws-sdk-go-v2/credentials v1.17.51 h1:F/9Sm6Y6k4LqDesZDPJCLxQGXNNHd/ZtJiWd0lCZKRk= -github.com/aws/aws-sdk-go-v2/credentials v1.17.51/go.mod h1:TKbzCHm43AoPyA+iLGGcruXd4AFhF8tOmLex2R9jWNQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23 h1:IBAoD/1d8A8/1aA8g4MBVtTRHhXRiNAgwdbo/xRM2DI= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23/go.mod h1:vfENuCM7dofkgKpYzuzf1VT1UKkA/YL3qanfBn7HCaA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27 h1:jSJjSBzw8VDIbWv+mmvBSP8ezsztMYJGH+eKqi9AmNs= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27/go.mod h1:/DAhLbFRgwhmvJdOfSm+WwikZrCuUJiA4WgJG0fTNSw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27 h1:l+X4K77Dui85pIj5foXDhPlnqcNRG2QUyvca300lXh8= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27/go.mod h1:KvZXSFEXm6x84yE8qffKvT3x8J5clWnVFXphpohhzJ8= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8 h1:cWno7lefSH6Pp+mSznagKCgfDGeZRin66UvYUqAkyeA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8/go.mod h1:tPD+VjU3ABTBoEJ3nctu5Nyg4P4yjqSH5bJGGkY4+XE= -github.com/aws/aws-sdk-go-v2/service/kms v1.37.8 h1:KbLZjYqhQ9hyB4HwXiheiflTlYQa0+Fz0Ms/rh5f3mk= -github.com/aws/aws-sdk-go-v2/service/kms v1.37.8/go.mod h1:ANs9kBhK4Ghj9z1W+bsr3WsNaPF71qkgd6eE6Ekol/Y= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.9 h1:YqtxripbjWb2QLyzRK9pByfEDvgg95gpC2AyDq4hFE8= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.9/go.mod h1:lV8iQpg6OLOfBnqbGMBKYjilBlf633qwHnBEiMSPoHY= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8 h1:6dBT1Lz8fK11m22R+AqfRsFn8320K0T5DTGxxOQBSMw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8/go.mod h1:/kiBvRQXBc6xeJTYzhSdGvJ5vm1tjaDEjH+MSeRJnlY= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 h1:VwhTrsTuVn52an4mXx29PqRzs2Dvu921NpGk7y43tAM= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.6/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc= -github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= -github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk= +github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= +github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2/config v1.29.10 h1:yNjgjiGBp4GgaJrGythyBXg2wAs+Im9fSWIUwvi1CAc= +github.com/aws/aws-sdk-go-v2/config v1.29.10/go.mod h1:A0mbLXSdtob/2t59n1X0iMkPQ5d+YzYZB4rwu7SZ7aA= +github.com/aws/aws-sdk-go-v2/credentials v1.17.63 h1:rv1V3kIJ14pdmTu01hwcMJ0WAERensSiD9rEWEBb1Tk= +github.com/aws/aws-sdk-go-v2/credentials v1.17.63/go.mod h1:EJj+yDf0txT26Ulo0VWTavBl31hOsaeuMxIHu2m0suY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= +github.com/aws/aws-sdk-go-v2/service/kms v1.38.1 h1:tecq7+mAav5byF+Mr+iONJnCBf4B4gon8RSp4BrweSc= +github.com/aws/aws-sdk-go-v2/service/kms v1.38.1/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.1 h1:8JdC7Gr9NROg1Rusk25IcZeTO59zLxsKgE0gkh5O6h0= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.1/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.2 h1:wK8O+j2dOolmpNVY1EWIbLgxrGCHJKVPm08Hv/u80M8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.2/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 h1:PZV5W8yk4OtH1JAuhV2PXwwO9v5G5Aoj+eMCn4T+1Kc= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= +github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= +github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -131,7 +130,6 @@ github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUo github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -167,8 +165,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= @@ -177,8 +175,6 @@ github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/ github.com/gdamore/tcell/v2 v2.5.4/go.mod h1:dZgRy5v4iMobMEcWNYBtREnDZAT9DYmfqIkrgEMxLyw= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= -github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= -github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -188,8 +184,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= -github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= -github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/errors v0.22.1 h1:kslMRRnK7NCb/CvR1q1VWuEQCEIsBGn5GgKD9e+HYhU= +github.com/go-openapi/errors v0.22.1/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -202,32 +198,34 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/certificate-transparency-go v1.3.1 h1:akbcTfQg0iZlANZLn0L9xOeWtyCIdeoYhKrqi5iH3Go= github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= -github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w= @@ -236,8 +234,8 @@ github.com/google/trillian v1.7.1 h1:+zX8jLM3524bAMPS+VxaDIDgsMv3/ty6DuLWerHXcek github.com/google/trillian v1.7.1/go.mod h1:E1UMAHqpZCA8AQdrKdWmHmtUfSeiD0sDWD1cv00Xa+c= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= -github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -265,13 +263,12 @@ github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0S github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw= github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA= -github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= +github.com/hashicorp/vault/api v1.16.0 h1:nbEYGJiAPGzT9U4oWgaaB0g+Rj8E59QuHKyA5LhwQN4= +github.com/hashicorp/vault/api v1.16.0/go.mod h1:KhuUhzOD8lDSk29AtzNjgAu2kxRA9jL9NAbkFlqvkBA= github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -330,10 +327,8 @@ github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec h1:2tTW6cDth2T github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec/go.mod h1:TmwEoGCwIti7BCeJ9hescZgRtatxRE+A72pCoPfmcfk= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -378,8 +373,8 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -387,12 +382,12 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= +github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d h1:jKIUJdMcIVGOSHi6LSqJqw9RqblyblE2ZrHvFbWR3S0= @@ -409,10 +404,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= @@ -429,51 +422,44 @@ github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc h1:vH0NQbIDk+mJL github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= -github.com/sigstore/protobuf-specs v0.3.3 h1:RMZQgXTD/pF7KW6b5NaRLYxFYZ/wzx44PQFXN2PEo5g= -github.com/sigstore/protobuf-specs v0.3.3/go.mod h1:vIhZ6Uor1a38+wvRrKcqL2PtYNlgoIW9lhzYzkyy4EU= -github.com/sigstore/rekor v1.3.8 h1:B8kJI8mpSIXova4Jxa6vXdJyysRxFGsEsLKBDl0rRjA= -github.com/sigstore/rekor v1.3.8/go.mod h1:/dHFYKSuxEygfDRnEwyJ+ZD6qoVYNXQdi1mJrKvKWsI= -github.com/sigstore/sigstore v1.8.12 h1:S8xMVZbE2z9ZBuQUEG737pxdLjnbOIcFi5v9UFfkJFc= -github.com/sigstore/sigstore v1.8.12/go.mod h1:+PYQAa8rfw0QdPpBcT+Gl3egKD9c+TUgAlF12H3Nmjo= -github.com/sigstore/sigstore-go v0.7.0 h1:bIGPc2IbnbxnzlqQcKlh1o96bxVJ4yRElpP1gHrOH48= -github.com/sigstore/sigstore-go v0.7.0/go.mod h1:4RrCK+i+jhx7lyOG2Vgef0/kFLbKlDI1hrioUYvkxxA= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.12 h1:EC3UmIaa7nV9sCgSpVevmvgvTYTkMqyrRbj5ojPp7tE= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.12/go.mod h1:aw60vs3crnQdM/DYH+yF2P0MVKtItwAX34nuaMrY7Lk= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.12 h1:FPpliDTywSy0woLHMAdmTSZ5IS/lVBZ0dY0I+2HmnSY= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.12/go.mod h1:NkPiz4XA0JcBSXzJUrjMj7Xi7oSTew1Ip3Zmt56mHlw= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.12 h1:kweBChR6M9FEvmxN3BMEcl7SNnwxTwKF7THYFKLOE5U= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.12/go.mod h1:6+d+A6oYt1W5OgtzgEVb21V7tAZ/C2Ihtzc5MNJbayY= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.12 h1:jvY1B9bjP+tKzdKDyuq5K7O19CG2IKzGJNTy5tuL2Gs= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.12/go.mod h1:2uEeOb8xE2RC6OvzxKux1wkS39Zv8gA27z92m49xUTc= -github.com/sigstore/timestamp-authority v1.2.4 h1:RjXZxOWorEiem/uSr0pFHVtQpyzpcFxgugo5jVqm3mw= -github.com/sigstore/timestamp-authority v1.2.4/go.mod h1:ExrbobKdEuwuBptZIiKp1IaVBRiUeKbiuSyZTO8Okik= +github.com/sigstore/protobuf-specs v0.4.1 h1:5SsMqZbdkcO/DNHudaxuCUEjj6x29tS2Xby1BxGU7Zc= +github.com/sigstore/protobuf-specs v0.4.1/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= +github.com/sigstore/rekor v1.3.9 h1:sUjRpKVh/hhgqGMs0t+TubgYsksArZ6poLEC3MsGAzU= +github.com/sigstore/rekor v1.3.9/go.mod h1:xThNUhm6eNEmkJ/SiU/FVU7pLY2f380fSDZFsdDWlcM= +github.com/sigstore/sigstore v1.9.1 h1:bNMsfFATsMPaagcf+uppLk4C9rQZ2dh5ysmCxQBYWaw= +github.com/sigstore/sigstore v1.9.1/go.mod h1:zUoATYzR1J3rLNp3jmp4fzIJtWdhC3ZM6MnpcBtnsE4= +github.com/sigstore/sigstore-go v0.7.1 h1:lyzi3AjO6+BHc5zCf9fniycqPYOt3RaC08M/FRmQhVY= +github.com/sigstore/sigstore-go v0.7.1/go.mod h1:AIRj4I3LC82qd07VFm3T2zXYiddxeBV1k/eoS8nTz0E= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.1 h1:/YcNq687WnXpIRXl04nLfJX741G4iW+w+7Nem2Zy0f4= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.1/go.mod h1:ApL9RpKsi7gkSYN0bMNdm/3jZ9EefxMmfYHfUmq2ZYM= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.1 h1:FnusXyTIInnwfIOzzl5PFilRm1I97dxMSOcCkZBu9Kc= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.1/go.mod h1:d5m5LOa/69a+t2YC9pDPwS1n2i/PhqB4cUKbpVDlKKE= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.1 h1:LFiYK1DEWQ6Hf/nroFzBMM+s5rVSjVL45Alpb5Ctl5A= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.1/go.mod h1:GFyFmDsE2wDuIHZD+4+JErGpA0S4zJsKNz5l2JVJd8s= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.1 h1:sIW6xe4yU5eIMH8fve2C78d+r29KmHnIb+7po+80bsY= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.1/go.mod h1:3pNf99GnK9eu3XUa5ebHzgEQSVYf9hqAoPFwbwD6O6M= +github.com/sigstore/timestamp-authority v1.2.5 h1:W22JmwRv1Salr/NFFuP7iJuhytcZszQjldoB8GiEdnw= +github.com/sigstore/timestamp-authority v1.2.5/go.mod h1:gWPKWq4HMWgPCETre0AakgBzcr9DRqHrsgbrRqsigOs= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= -github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -484,6 +470,12 @@ github.com/theupdateframework/go-tuf/v2 v2.0.2 h1:PyNnjV9BJNzN1ZE6BcWK+5JbF+if37 github.com/theupdateframework/go-tuf/v2 v2.0.2/go.mod h1:baB22nBHeHBCeuGZcIlctNq4P61PcOdyARlplg5xmLA= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI= +github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= +github.com/tink-crypto/tink-go/v2 v2.3.0 h1:4/TA0lw0lA/iVKBL9f8R5eP7397bfc4antAMXF5JRhs= +github.com/tink-crypto/tink-go/v2 v2.3.0/go.mod h1:kfPOtXIadHlekBTeBtJrHWqoGL+Fm3JQg0wtltPuxLU= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= @@ -504,22 +496,22 @@ go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= -go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= -go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= -go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= -go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= -go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= -go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= -go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= -go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= -go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= -go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= -go.step.sm/crypto v0.57.0 h1:YjoRQDaJYAxHLVwjst0Bl0xcnoKzVwuHCJtEo2VSHYU= -go.step.sm/crypto v0.57.0/go.mod h1:+Lwp5gOVPaTa3H/Ul/TzGbxQPXZZcKIUGMS0lG6n9Go= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.step.sm/crypto v0.60.0 h1:UgSw8DFG5xUOGB3GUID17UA32G4j1iNQ4qoMhBmsVFw= +go.step.sm/crypto v0.60.0/go.mod h1:Ep83Lv818L4gV0vhFTdPWRKnL6/5fRMpi8SaoP5ArSw= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -528,24 +520,24 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= -golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= -golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= -golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= -golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -555,46 +547,44 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.216.0 h1:xnEHy+xWFrtYInWPy8OdGFsyIfWJjtVnO39g7pz2BFY= -google.golang.org/api v0.216.0/go.mod h1:K9wzQMvWi47Z9IU7OgdOofvZuw75Ge3PPITImZR/UyI= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc= -google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= -google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= -google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= -google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/api v0.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc= +google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 35ec7f251ceec5259d025993b454f3b01c4d35e7 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 8 Apr 2025 15:20:51 -0600 Subject: [PATCH 058/147] replace sigstore-go test bundle func Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/test/data/data.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/attestation/test/data/data.go b/pkg/cmd/attestation/test/data/data.go index b33efaa28..ef3c35c20 100644 --- a/pkg/cmd/attestation/test/data/data.go +++ b/pkg/cmd/attestation/test/data/data.go @@ -5,13 +5,17 @@ import ( "testing" "github.com/sigstore/sigstore-go/pkg/bundle" - sgData "github.com/sigstore/sigstore-go/pkg/testing/data" ) //go:embed sigstore-js-2.1.0-bundle.json var SigstoreBundleRaw []byte -// SigstoreBundle returns a test *sigstore.Bundle +// SigstoreBundle returns a test sigstore-go bundle.Bundle func SigstoreBundle(t *testing.T) *bundle.Bundle { - return sgData.TestBundle(t, SigstoreBundleRaw) + b := &bundle.Bundle{} + err := b.UnmarshalJSON(SigstoreBundleRaw) + if err != nil { + t.Fatalf("failed to unmarshal sigstore bundle: %v", err) + } + return b } From 323ea74733074bfd012192d59ae12d58cdf97e12 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 8 Apr 2025 15:40:55 -0600 Subject: [PATCH 059/147] add public good and github verifiers as fields for repeated use instead of creating a new one for every attestation Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verification/sigstore.go | 30 +++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 912e21601..4dcd3c82c 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -44,9 +44,11 @@ type SigstoreVerifier interface { } type LiveSigstoreVerifier struct { - TrustedRoot string - Logger *io.Handler - NoPublicGood bool + TrustedRoot string + Logger *io.Handler + NoPublicGood bool + PublicGoodVerifier *verify.SignedEntityVerifier + GitHubVerifier *verify.SignedEntityVerifier // If tenancy mode is not used, trust domain is empty TrustDomain string TUFMetadataDir o.Option[string] @@ -86,17 +88,31 @@ func getBundleIssuer(b *bundle.Bundle) (string, error) { } func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) { - // if no custom trusted root is set, attempt to create a Public Good or - // GitHub Sigstore verifier + // if no custom trusted root is set, return either the Public Good or GitHub verifier + // If the chosen verifier has not yet been created, create it as a LiveSigstoreVerifier field for use in future calls if v.TrustedRoot == "" { switch issuer { case PublicGoodIssuerOrg: if v.NoPublicGood { return nil, fmt.Errorf("detected public good instance but requested verification without public good instance") } - return newPublicGoodVerifier(v.TUFMetadataDir) + if v.PublicGoodVerifier == nil { + publicGood, err := newPublicGoodVerifier(v.TUFMetadataDir) + if err != nil { + return nil, err + } + v.PublicGoodVerifier = publicGood + } + return v.PublicGoodVerifier, nil case GitHubIssuerOrg: - return newGitHubVerifier(v.TrustDomain, v.TUFMetadataDir) + if v.GitHubVerifier == nil { + github, err := newGitHubVerifier(v.TrustDomain, v.TUFMetadataDir) + if err != nil { + return nil, err + } + v.GitHubVerifier = github + } + return v.GitHubVerifier, nil default: return nil, fmt.Errorf("leaf certificate issuer is not recognized") } From 366485155ede3536ed7f43e4b69cb59ed65b3e55 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 8 Apr 2025 16:23:37 -0600 Subject: [PATCH 060/147] initiate custom verifiers when the sgistore verifier is created Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/inspect/inspect.go | 6 +- pkg/cmd/attestation/verification/sigstore.go | 162 ++++++++++++------ .../verification/sigstore_integration_test.go | 15 +- .../verify/attestation_integration_test.go | 3 +- pkg/cmd/attestation/verify/verify.go | 6 +- .../verify/verify_integration_test.go | 22 ++- 6 files changed, 144 insertions(+), 70 deletions(-) diff --git a/pkg/cmd/attestation/inspect/inspect.go b/pkg/cmd/attestation/inspect/inspect.go index 6fbddd6da..b571eee01 100644 --- a/pkg/cmd/attestation/inspect/inspect.go +++ b/pkg/cmd/attestation/inspect/inspect.go @@ -105,7 +105,11 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command config.TrustDomain = td } - opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config) + sgVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + return fmt.Errorf("failed to create Sigstore verifier: %w", err) + } + opts.SigstoreVerifier = sgVerifier if runF != nil { return runF(opts) diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 4dcd3c82c..74251cffb 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -49,6 +49,7 @@ type LiveSigstoreVerifier struct { NoPublicGood bool PublicGoodVerifier *verify.SignedEntityVerifier GitHubVerifier *verify.SignedEntityVerifier + CustomVerifiers map[string]*verify.SignedEntityVerifier // If tenancy mode is not used, trust domain is empty TrustDomain string TUFMetadataDir o.Option[string] @@ -59,14 +60,111 @@ var ErrNoAttestationsVerified = errors.New("no attestations were verified") // NewLiveSigstoreVerifier creates a new LiveSigstoreVerifier struct // that is used to verify artifacts and attestations against the // Public Good, GitHub, or a custom trusted root. -func NewLiveSigstoreVerifier(config SigstoreConfig) *LiveSigstoreVerifier { - return &LiveSigstoreVerifier{ +func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, error) { + liveVerifier := &LiveSigstoreVerifier{ TrustedRoot: config.TrustedRoot, Logger: config.Logger, NoPublicGood: config.NoPublicGood, TrustDomain: config.TrustDomain, TUFMetadataDir: config.TUFMetadataDir, } + // if a custom trusted root is set, configure custom verifiers + if config.TrustedRoot != "" { + customVerifiers, err := createCustomVerifiers(config.TrustedRoot, config.NoPublicGood) + if err != nil { + return nil, err + } + liveVerifier.CustomVerifiers = customVerifiers + return liveVerifier, nil + } + + return liveVerifier, nil +} + +func createCustomVerifiers(trustedRoot string, noPublicGood bool) (map[string]*verify.SignedEntityVerifier, error) { + verifiers := make(map[string]*verify.SignedEntityVerifier) + + customTrustRoots, err := os.ReadFile(trustedRoot) + if err != nil { + return nil, fmt.Errorf("unable to read file %s: %v", trustedRoot, err) + } + + reader := bufio.NewReader(bytes.NewReader(customTrustRoots)) + var line []byte + var readError error + line, readError = reader.ReadBytes('\n') + for readError == nil { + // Load each trusted root + trustedRoot, err := root.NewTrustedRootFromJSON(line) + if err != nil { + return nil, fmt.Errorf("failed to create custom verifier: %v", err) + } + + // Compare bundle leafCert issuer with trusted root cert authority + certAuthorities := trustedRoot.FulcioCertificateAuthorities() + for _, certAuthority := range certAuthorities { + fulcioCertAuthority, ok := certAuthority.(*root.FulcioCertificateAuthority) + if !ok { + return nil, fmt.Errorf("trusted root cert authority is not a FulcioCertificateAuthority") + } + lowestCert, err := getLowestCertInChain(fulcioCertAuthority) + if err != nil { + return nil, err + } + + // if the custom trusted root issuer is not set, skip it + if len(lowestCert.Issuer.Organization) == 0 { + continue + } + issuer := lowestCert.Issuer.Organization[0] + + // Determine what policy to use with this trusted root. + // + // Note that we are *only* inferring the policy with the + // issuer. We *must* use the trusted root provided. + switch issuer { + case PublicGoodIssuerOrg: + if noPublicGood { + return nil, fmt.Errorf("detected public good instance but requested verification without public good instance") + } + if _, ok := verifiers[PublicGoodIssuerOrg]; ok { + // we have already created a public good verifier with this custom trusted root + // so we skip it + continue + } + publicGood, err := newPublicGoodVerifierWithTrustedRoot(trustedRoot) + if err != nil { + return nil, err + } + verifiers[PublicGoodIssuerOrg] = publicGood + case GitHubIssuerOrg: + if _, ok := verifiers[GitHubIssuerOrg]; ok { + // we have already created a github verifier with this custom trusted root + // so we skip it + continue + } + github, err := newGitHubVerifierWithTrustedRoot(trustedRoot) + if err != nil { + return nil, err + } + verifiers[GitHubIssuerOrg] = github + default: + if _, ok := verifiers[issuer]; ok { + // we have already created a custom verifier with this custom trusted root + // so we skip it + continue + } + // Make best guess at reasonable policy + custom, err := newCustomVerifier(trustedRoot) + if err != nil { + return nil, err + } + verifiers[issuer] = custom + } + } + line, readError = reader.ReadBytes('\n') + } + return verifiers, nil } func getBundleIssuer(b *bundle.Bundle) (string, error) { @@ -90,7 +188,7 @@ func getBundleIssuer(b *bundle.Bundle) (string, error) { func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) { // if no custom trusted root is set, return either the Public Good or GitHub verifier // If the chosen verifier has not yet been created, create it as a LiveSigstoreVerifier field for use in future calls - if v.TrustedRoot == "" { + if v.CustomVerifiers == nil { switch issuer { case PublicGoodIssuerOrg: if v.NoPublicGood { @@ -118,60 +216,12 @@ func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEnti } } - customTrustRoots, err := os.ReadFile(v.TrustedRoot) - if err != nil { - return nil, fmt.Errorf("unable to read file %s: %v", v.TrustedRoot, err) + custom, ok := v.CustomVerifiers[issuer] + if !ok { + return nil, fmt.Errorf("no custom verifier found for issuer \"%s\"", issuer) + //return nil, fmt.Errorf("unable to use provided trusted roots") } - - reader := bufio.NewReader(bytes.NewReader(customTrustRoots)) - var line []byte - var readError error - line, readError = reader.ReadBytes('\n') - for readError == nil { - // Load each trusted root - trustedRoot, err := root.NewTrustedRootFromJSON(line) - if err != nil { - return nil, fmt.Errorf("failed to create custom verifier: %v", err) - } - - // Compare bundle leafCert issuer with trusted root cert authority - certAuthorities := trustedRoot.FulcioCertificateAuthorities() - for _, certAuthority := range certAuthorities { - fulcioCertAuthority, ok := certAuthority.(*root.FulcioCertificateAuthority) - if !ok { - return nil, fmt.Errorf("trusted root cert authority is not a FulcioCertificateAuthority") - } - lowestCert, err := getLowestCertInChain(fulcioCertAuthority) - if err != nil { - return nil, err - } - - // if the custom trusted root issuer is not set or doesn't match the given issuer, skip it - if len(lowestCert.Issuer.Organization) == 0 || lowestCert.Issuer.Organization[0] != issuer { - continue - } - - // Determine what policy to use with this trusted root. - // - // Note that we are *only* inferring the policy with the - // issuer. We *must* use the trusted root provided. - switch issuer { - case PublicGoodIssuerOrg: - if v.NoPublicGood { - return nil, fmt.Errorf("detected public good instance but requested verification without public good instance") - } - return newPublicGoodVerifierWithTrustedRoot(trustedRoot) - case GitHubIssuerOrg: - return newGitHubVerifierWithTrustedRoot(trustedRoot) - default: - // Make best guess at reasonable policy - return newCustomVerifier(trustedRoot) - } - } - line, readError = reader.ReadBytes('\n') - } - - return nil, fmt.Errorf("unable to use provided trusted roots") + return custom, nil } func getLowestCertInChain(ca *root.FulcioCertificateAuthority) (*x509.Certificate, error) { diff --git a/pkg/cmd/attestation/verification/sigstore_integration_test.go b/pkg/cmd/attestation/verification/sigstore_integration_test.go index 987fb9caa..2a2d3beea 100644 --- a/pkg/cmd/attestation/verification/sigstore_integration_test.go +++ b/pkg/cmd/attestation/verification/sigstore_integration_test.go @@ -50,10 +50,11 @@ func TestLiveSigstoreVerifier(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { - verifier := NewLiveSigstoreVerifier(SigstoreConfig{ + verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) + require.NoError(t, err) results, err := verifier.Verify(tc.attestations, publicGoodPolicy(t)) @@ -69,10 +70,11 @@ func TestLiveSigstoreVerifier(t *testing.T) { } t.Run("with 2/3 verified attestations", func(t *testing.T) { - verifier := NewLiveSigstoreVerifier(SigstoreConfig{ + verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) + require.NoError(t, err) invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json") attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") @@ -86,10 +88,11 @@ func TestLiveSigstoreVerifier(t *testing.T) { }) t.Run("fail with 0/2 verified attestations", func(t *testing.T) { - verifier := NewLiveSigstoreVerifier(SigstoreConfig{ + verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) + require.NoError(t, err) invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json") attestations := getAttestationsFor(t, "../test/data/sigstoreBundle-invalid-signature.json") @@ -110,10 +113,11 @@ func TestLiveSigstoreVerifier(t *testing.T) { attestations := getAttestationsFor(t, "../test/data/github_provenance_demo-0.0.12-py3-none-any-bundle.jsonl") - verifier := NewLiveSigstoreVerifier(SigstoreConfig{ + verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) + require.NoError(t, err) results, err := verifier.Verify(attestations, githubPolicy) require.Len(t, results, 1) @@ -123,11 +127,12 @@ func TestLiveSigstoreVerifier(t *testing.T) { t.Run("with custom trusted root", func(t *testing.T) { attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") - verifier := NewLiveSigstoreVerifier(SigstoreConfig{ + verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ Logger: io.NewTestHandler(), TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"), TUFMetadataDir: o.Some(t.TempDir()), }) + require.NoError(t, err) results, err := verifier.Verify(attestations, publicGoodPolicy(t)) require.Len(t, results, 2) diff --git a/pkg/cmd/attestation/verify/attestation_integration_test.go b/pkg/cmd/attestation/verify/attestation_integration_test.go index 9ff174141..73452c425 100644 --- a/pkg/cmd/attestation/verify/attestation_integration_test.go +++ b/pkg/cmd/attestation/verify/attestation_integration_test.go @@ -25,10 +25,11 @@ func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation { } func TestVerifyAttestations(t *testing.T) { - sgVerifier := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ + sgVerifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) + require.NoError(t, err) certSummary := certificate.Summary{} certSummary.SourceRepositoryOwnerURI = "https://github.com/sigstore" diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index 3affdfabb..b3bad519a 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -211,7 +211,11 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command return runF(opts) } - opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config) + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + return fmt.Errorf("error creating Sigstore verifier: %w", err) + } + opts.SigstoreVerifier = sigstoreVerifier opts.Config = f.Config if err := runVerify(opts); err != nil { diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index 09479995c..92864f78e 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -33,6 +33,8 @@ func TestVerifyIntegration(t *testing.T) { host, _ := auth.DefaultHost() + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) + require.NoError(t, err) publicGoodOpts := Options{ APIClient: api.NewLiveClient(hc, host, logger), ArtifactPath: artifactPath, @@ -44,7 +46,7 @@ func TestVerifyIntegration(t *testing.T) { Owner: "sigstore", PredicateType: verification.SLSAPredicateV1, SANRegex: "^https://github.com/sigstore/", - SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig), + SigstoreVerifier: sigstoreVerifier, } t.Run("with valid owner", func(t *testing.T) { @@ -106,6 +108,8 @@ func TestVerifyIntegration(t *testing.T) { }) t.Run("with bundle from OCI registry", func(t *testing.T) { + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) + require.NoError(t, err) opts := Options{ APIClient: api.NewLiveClient(hc, host, logger), ArtifactPath: "oci://ghcr.io/github/artifact-attestations-helm-charts/policy-controller:v0.10.0-github9", @@ -117,10 +121,10 @@ func TestVerifyIntegration(t *testing.T) { Owner: "github", PredicateType: verification.SLSAPredicateV1, SANRegex: "^https://github.com/github/", - SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig), + SigstoreVerifier: sigstoreVerifier, } - err := runVerify(&opts) + err = runVerify(&opts) require.NoError(t, err) }) } @@ -145,6 +149,8 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { host, _ := auth.DefaultHost() + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) + require.NoError(t, err) baseOpts := Options{ APIClient: api.NewLiveClient(hc, host, logger), ArtifactPath: artifactPath, @@ -154,7 +160,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { OCIClient: oci.NewLiveClient(), OIDCIssuer: "https://token.actions.githubusercontent.com/hammer-time", PredicateType: verification.SLSAPredicateV1, - SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig), + SigstoreVerifier: sigstoreVerifier, } t.Run("with owner and valid workflow SAN", func(t *testing.T) { @@ -216,6 +222,8 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { host, _ := auth.DefaultHost() + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) + require.NoError(t, err) baseOpts := Options{ APIClient: api.NewLiveClient(hc, host, logger), ArtifactPath: artifactPath, @@ -225,7 +233,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { OCIClient: oci.NewLiveClient(), OIDCIssuer: verification.GitHubOIDCIssuer, PredicateType: verification.SLSAPredicateV1, - SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig), + SigstoreVerifier: sigstoreVerifier, } t.Run("with owner and valid reusable workflow SAN", func(t *testing.T) { @@ -306,6 +314,8 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { host, _ := auth.DefaultHost() + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) + require.NoError(t, err) baseOpts := Options{ APIClient: api.NewLiveClient(hc, host, logger), ArtifactPath: artifactPath, @@ -318,7 +328,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { Owner: "malancas", PredicateType: verification.SLSAPredicateV1, Repo: "malancas/attest-demo", - SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig), + SigstoreVerifier: sigstoreVerifier, } type testcase struct { From a535cfdbfccfc1f6fbd4252571a3b385ab6ff6fc Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 8 Apr 2025 16:28:56 -0600 Subject: [PATCH 061/147] flip verifier choice logic Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verification/sigstore.go | 60 ++++++++++---------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 74251cffb..a244b470e 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -188,40 +188,38 @@ func getBundleIssuer(b *bundle.Bundle) (string, error) { func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) { // if no custom trusted root is set, return either the Public Good or GitHub verifier // If the chosen verifier has not yet been created, create it as a LiveSigstoreVerifier field for use in future calls - if v.CustomVerifiers == nil { - switch issuer { - case PublicGoodIssuerOrg: - if v.NoPublicGood { - return nil, fmt.Errorf("detected public good instance but requested verification without public good instance") - } - if v.PublicGoodVerifier == nil { - publicGood, err := newPublicGoodVerifier(v.TUFMetadataDir) - if err != nil { - return nil, err - } - v.PublicGoodVerifier = publicGood - } - return v.PublicGoodVerifier, nil - case GitHubIssuerOrg: - if v.GitHubVerifier == nil { - github, err := newGitHubVerifier(v.TrustDomain, v.TUFMetadataDir) - if err != nil { - return nil, err - } - v.GitHubVerifier = github - } - return v.GitHubVerifier, nil - default: - return nil, fmt.Errorf("leaf certificate issuer is not recognized") + if v.CustomVerifiers != nil { + custom, ok := v.CustomVerifiers[issuer] + if !ok { + return nil, fmt.Errorf("no custom verifier found for issuer \"%s\"", issuer) } + return custom, nil } - - custom, ok := v.CustomVerifiers[issuer] - if !ok { - return nil, fmt.Errorf("no custom verifier found for issuer \"%s\"", issuer) - //return nil, fmt.Errorf("unable to use provided trusted roots") + switch issuer { + case PublicGoodIssuerOrg: + if v.NoPublicGood { + return nil, fmt.Errorf("detected public good instance but requested verification without public good instance") + } + if v.PublicGoodVerifier == nil { + publicGood, err := newPublicGoodVerifier(v.TUFMetadataDir) + if err != nil { + return nil, err + } + v.PublicGoodVerifier = publicGood + } + return v.PublicGoodVerifier, nil + case GitHubIssuerOrg: + if v.GitHubVerifier == nil { + github, err := newGitHubVerifier(v.TrustDomain, v.TUFMetadataDir) + if err != nil { + return nil, err + } + v.GitHubVerifier = github + } + return v.GitHubVerifier, nil + default: + return nil, fmt.Errorf("leaf certificate issuer is not recognized") } - return custom, nil } func getLowestCertInChain(ca *root.FulcioCertificateAuthority) (*x509.Certificate, error) { From d63e2830e00d108a21256a79841e7d26f54c798e Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 8 Apr 2025 16:35:17 -0600 Subject: [PATCH 062/147] clean up unneeded sigstore verifier fields Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verification/sigstore.go | 57 +++++++++----------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index a244b470e..63e87ebfc 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -44,15 +44,11 @@ type SigstoreVerifier interface { } type LiveSigstoreVerifier struct { - TrustedRoot string - Logger *io.Handler - NoPublicGood bool - PublicGoodVerifier *verify.SignedEntityVerifier - GitHubVerifier *verify.SignedEntityVerifier - CustomVerifiers map[string]*verify.SignedEntityVerifier - // If tenancy mode is not used, trust domain is empty - TrustDomain string - TUFMetadataDir o.Option[string] + Logger *io.Handler + NoPublicGood bool + PublicGood *verify.SignedEntityVerifier + GitHub *verify.SignedEntityVerifier + Custom map[string]*verify.SignedEntityVerifier } var ErrNoAttestationsVerified = errors.New("no attestations were verified") @@ -62,11 +58,8 @@ var ErrNoAttestationsVerified = errors.New("no attestations were verified") // Public Good, GitHub, or a custom trusted root. func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, error) { liveVerifier := &LiveSigstoreVerifier{ - TrustedRoot: config.TrustedRoot, - Logger: config.Logger, - NoPublicGood: config.NoPublicGood, - TrustDomain: config.TrustDomain, - TUFMetadataDir: config.TUFMetadataDir, + Logger: config.Logger, + NoPublicGood: config.NoPublicGood, } // if a custom trusted root is set, configure custom verifiers if config.TrustedRoot != "" { @@ -74,9 +67,21 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro if err != nil { return nil, err } - liveVerifier.CustomVerifiers = customVerifiers + liveVerifier.Custom = customVerifiers return liveVerifier, nil } + if !config.NoPublicGood { + publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir) + if err != nil { + return nil, err + } + liveVerifier.PublicGood = publicGoodVerifier + } + github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir) + if err != nil { + return nil, err + } + liveVerifier.GitHub = github return liveVerifier, nil } @@ -188,8 +193,8 @@ func getBundleIssuer(b *bundle.Bundle) (string, error) { func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) { // if no custom trusted root is set, return either the Public Good or GitHub verifier // If the chosen verifier has not yet been created, create it as a LiveSigstoreVerifier field for use in future calls - if v.CustomVerifiers != nil { - custom, ok := v.CustomVerifiers[issuer] + if v.Custom != nil { + custom, ok := v.Custom[issuer] if !ok { return nil, fmt.Errorf("no custom verifier found for issuer \"%s\"", issuer) } @@ -200,23 +205,9 @@ func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEnti if v.NoPublicGood { return nil, fmt.Errorf("detected public good instance but requested verification without public good instance") } - if v.PublicGoodVerifier == nil { - publicGood, err := newPublicGoodVerifier(v.TUFMetadataDir) - if err != nil { - return nil, err - } - v.PublicGoodVerifier = publicGood - } - return v.PublicGoodVerifier, nil + return v.PublicGood, nil case GitHubIssuerOrg: - if v.GitHubVerifier == nil { - github, err := newGitHubVerifier(v.TrustDomain, v.TUFMetadataDir) - if err != nil { - return nil, err - } - v.GitHubVerifier = github - } - return v.GitHubVerifier, nil + return v.GitHub, nil default: return nil, fmt.Errorf("leaf certificate issuer is not recognized") } From 29080dc70a87e55671b7c00c949560df03c08afc Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 8 Apr 2025 16:40:52 -0600 Subject: [PATCH 063/147] reorganize func Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verification/sigstore.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 63e87ebfc..190ea5c0f 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -87,13 +87,12 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro } func createCustomVerifiers(trustedRoot string, noPublicGood bool) (map[string]*verify.SignedEntityVerifier, error) { - verifiers := make(map[string]*verify.SignedEntityVerifier) - customTrustRoots, err := os.ReadFile(trustedRoot) if err != nil { return nil, fmt.Errorf("unable to read file %s: %v", trustedRoot, err) } + verifiers := make(map[string]*verify.SignedEntityVerifier) reader := bufio.NewReader(bytes.NewReader(customTrustRoots)) var line []byte var readError error From de8778797f85e9db7cca3bfca3bb9e11cd07b141 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 8 Apr 2025 19:09:52 -0600 Subject: [PATCH 064/147] temporarily skip non-failing tests Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/api/client_test.go | 18 ++++++++++++++++++ .../artifact/artifact_posix_test.go | 1 + .../artifact/artifact_windows_test.go | 1 + .../attestation/artifact/digest/digest_test.go | 2 ++ pkg/cmd/attestation/artifact/image_test.go | 3 +++ .../attestation/artifact/oci/client_test.go | 4 ++++ pkg/cmd/attestation/auth/host_test.go | 1 + pkg/cmd/attestation/download/download_test.go | 2 ++ pkg/cmd/attestation/download/metadata_test.go | 1 + pkg/cmd/attestation/download/options_test.go | 1 + pkg/cmd/attestation/inspect/bundle_test.go | 2 ++ pkg/cmd/attestation/inspect/inspect_test.go | 3 +++ .../trustedroot/trustedroot_test.go | 3 +++ .../verification/attestation_test.go | 5 +++++ .../verification/extensions_test.go | 1 + pkg/cmd/attestation/verification/tuf_test.go | 1 + pkg/cmd/attestation/verify/options_test.go | 1 + pkg/cmd/attestation/verify/policy_test.go | 1 + .../verify/verify_integration_test.go | 4 ++++ pkg/cmd/attestation/verify/verify_test.go | 4 ++++ 20 files changed, 59 insertions(+) diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go index 787408a4e..0e0827295 100644 --- a/pkg/cmd/attestation/api/client_test.go +++ b/pkg/cmd/attestation/api/client_test.go @@ -43,6 +43,7 @@ func NewClientWithMockGHClient(hasNextPage bool) Client { } func TestGetByDigest(t *testing.T) { + t.Skip() c := NewClientWithMockGHClient(false) attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit) require.NoError(t, err) @@ -60,6 +61,7 @@ func TestGetByDigest(t *testing.T) { } func TestGetByDigestGreaterThanLimit(t *testing.T) { + t.Skip() c := NewClientWithMockGHClient(false) limit := 3 @@ -80,6 +82,7 @@ func TestGetByDigestGreaterThanLimit(t *testing.T) { } func TestGetByDigestWithNextPage(t *testing.T) { + t.Skip() c := NewClientWithMockGHClient(true) attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit) require.NoError(t, err) @@ -97,6 +100,7 @@ func TestGetByDigestWithNextPage(t *testing.T) { } func TestGetByDigestGreaterThanLimitWithNextPage(t *testing.T) { + t.Skip() c := NewClientWithMockGHClient(true) limit := 7 @@ -117,6 +121,7 @@ func TestGetByDigestGreaterThanLimitWithNextPage(t *testing.T) { } func TestGetByDigest_NoAttestationsFound(t *testing.T) { + t.Skip() fetcher := mockDataGenerator{ NumAttestations: 5, } @@ -142,6 +147,7 @@ func TestGetByDigest_NoAttestationsFound(t *testing.T) { } func TestGetByDigest_Error(t *testing.T) { + t.Skip() fetcher := mockDataGenerator{ NumAttestations: 5, } @@ -163,6 +169,7 @@ func TestGetByDigest_Error(t *testing.T) { } func TestFetchBundleFromAttestations_BundleURL(t *testing.T) { + t.Skip() httpClient := &mockHttpClient{} client := LiveClient{ httpClient: httpClient, @@ -180,6 +187,7 @@ func TestFetchBundleFromAttestations_BundleURL(t *testing.T) { } func TestFetchBundleFromAttestations_MissingBundleAndBundleURLFields(t *testing.T) { + t.Skip() httpClient := &mockHttpClient{} client := LiveClient{ httpClient: httpClient, @@ -196,6 +204,7 @@ func TestFetchBundleFromAttestations_MissingBundleAndBundleURLFields(t *testing. } func TestFetchBundleFromAttestations_FailOnTheSecondAttestation(t *testing.T) { + t.Skip() mockHTTPClient := &failAfterNCallsHttpClient{ // the initial HTTP request will succeed, which returns a bundle for the first attestation // all following HTTP requests will fail, which means the function fails to fetch a bundle @@ -218,6 +227,7 @@ func TestFetchBundleFromAttestations_FailOnTheSecondAttestation(t *testing.T) { } func TestFetchBundleFromAttestations_FailAfterRetrying(t *testing.T) { + t.Skip() mockHTTPClient := &reqFailHttpClient{} c := &LiveClient{ @@ -234,6 +244,7 @@ func TestFetchBundleFromAttestations_FailAfterRetrying(t *testing.T) { } func TestFetchBundleFromAttestations_FallbackToBundleField(t *testing.T) { + t.Skip() mockHTTPClient := &mockHttpClient{} c := &LiveClient{ @@ -252,6 +263,7 @@ func TestFetchBundleFromAttestations_FallbackToBundleField(t *testing.T) { // getBundle successfully fetches a bundle on the first HTTP request attempt func TestGetBundle(t *testing.T) { + t.Skip() mockHTTPClient := &mockHttpClient{} c := &LiveClient{ @@ -268,6 +280,7 @@ func TestGetBundle(t *testing.T) { // getBundle retries successfully when the initial HTTP request returns // a 5XX status code func TestGetBundle_SuccessfulRetry(t *testing.T) { + t.Skip() mockHTTPClient := &failAfterNCallsHttpClient{ FailOnCallN: 1, FailOnAllSubsequentCalls: false, @@ -286,6 +299,7 @@ func TestGetBundle_SuccessfulRetry(t *testing.T) { // getBundle does not retry when the function fails with a permanent backoff error condition func TestGetBundle_PermanentBackoffFail(t *testing.T) { + t.Skip() mockHTTPClient := &invalidBundleClient{} c := &LiveClient{ httpClient: mockHTTPClient, @@ -302,6 +316,7 @@ func TestGetBundle_PermanentBackoffFail(t *testing.T) { // getBundle retries when the HTTP request fails func TestGetBundle_RequestFail(t *testing.T) { + t.Skip() mockHTTPClient := &reqFailHttpClient{} c := &LiveClient{ @@ -316,6 +331,7 @@ func TestGetBundle_RequestFail(t *testing.T) { } func TestGetTrustDomain(t *testing.T) { + t.Skip() fetcher := mockMetaGenerator{ TrustDomain: "foo", } @@ -348,6 +364,7 @@ func TestGetTrustDomain(t *testing.T) { } func TestGetAttestationsRetries(t *testing.T) { + t.Skip() getAttestationRetryInterval = 0 fetcher := mockDataGenerator{ @@ -388,6 +405,7 @@ func TestGetAttestationsRetries(t *testing.T) { // test total retries func TestGetAttestationsMaxRetries(t *testing.T) { + t.Skip() getAttestationRetryInterval = 0 fetcher := mockDataGenerator{ diff --git a/pkg/cmd/attestation/artifact/artifact_posix_test.go b/pkg/cmd/attestation/artifact/artifact_posix_test.go index 31e9cb7e7..2fca69cbb 100644 --- a/pkg/cmd/attestation/artifact/artifact_posix_test.go +++ b/pkg/cmd/attestation/artifact/artifact_posix_test.go @@ -10,6 +10,7 @@ import ( ) func TestNormalizeReference(t *testing.T) { + t.Skip() testCases := []struct { name string reference string diff --git a/pkg/cmd/attestation/artifact/artifact_windows_test.go b/pkg/cmd/attestation/artifact/artifact_windows_test.go index 46995f226..4648be0a5 100644 --- a/pkg/cmd/attestation/artifact/artifact_windows_test.go +++ b/pkg/cmd/attestation/artifact/artifact_windows_test.go @@ -10,6 +10,7 @@ import ( ) func TestNormalizeReference(t *testing.T) { + t.Skip() testCases := []struct { name string reference string diff --git a/pkg/cmd/attestation/artifact/digest/digest_test.go b/pkg/cmd/attestation/artifact/digest/digest_test.go index bcfd2c1ac..2fb7727f5 100644 --- a/pkg/cmd/attestation/artifact/digest/digest_test.go +++ b/pkg/cmd/attestation/artifact/digest/digest_test.go @@ -9,6 +9,7 @@ import ( ) func TestArtifactDigestWithAlgorithm(t *testing.T) { + t.Skip() testString := "deadbeef" sha512TestDigest := "113a3bc783d851fc0373214b19ea7be9fa3de541ecb9fe026d52c603e8ea19c174cc0e9705f8b90d312212c0c3a6d8453ddfb3e3141409cf4bedc8ef033590b4" sha256TestDigest := "2baf1f40105d9501fe319a8ec463fdf4325a2a5df445adf3f572f626253678c9" @@ -36,6 +37,7 @@ func TestArtifactDigestWithAlgorithm(t *testing.T) { } func TestValidDigestAlgorithms(t *testing.T) { + t.Skip() t.Run("includes sha256", func(t *testing.T) { assert.Contains(t, ValidDigestAlgorithms(), "sha256") }) diff --git a/pkg/cmd/attestation/artifact/image_test.go b/pkg/cmd/attestation/artifact/image_test.go index 5ea5f9a37..ad0c1f406 100644 --- a/pkg/cmd/attestation/artifact/image_test.go +++ b/pkg/cmd/attestation/artifact/image_test.go @@ -9,6 +9,7 @@ import ( ) func TestDigestContainerImageArtifact(t *testing.T) { + t.Skip() expectedDigest := "1234567890abcdef" client := oci.MockClient{} url := "example.com/repo:tag" @@ -20,6 +21,7 @@ func TestDigestContainerImageArtifact(t *testing.T) { } func TestParseImageRefFailure(t *testing.T) { + t.Skip() client := oci.ReferenceFailClient{} url := "example.com/repo:tag" _, err := digestContainerImageArtifact(url, client) @@ -27,6 +29,7 @@ func TestParseImageRefFailure(t *testing.T) { } func TestFetchImageFailure(t *testing.T) { + t.Skip() testcase := []struct { name string client oci.Client diff --git a/pkg/cmd/attestation/artifact/oci/client_test.go b/pkg/cmd/attestation/artifact/oci/client_test.go index a46533366..73cf9b42d 100644 --- a/pkg/cmd/attestation/artifact/oci/client_test.go +++ b/pkg/cmd/attestation/artifact/oci/client_test.go @@ -13,6 +13,7 @@ import ( ) func TestGetImageDigest_Success(t *testing.T) { + t.Skip() expectedDigest := v1.Hash{ Hex: "1234567890abcdef", Algorithm: "sha256", @@ -37,6 +38,7 @@ func TestGetImageDigest_Success(t *testing.T) { } func TestGetImageDigest_ReferenceFail(t *testing.T) { + t.Skip() c := LiveClient{ parseReference: func(string, ...name.Option) (name.Reference, error) { return nil, fmt.Errorf("failed to parse reference") @@ -53,6 +55,7 @@ func TestGetImageDigest_ReferenceFail(t *testing.T) { } func TestGetImageDigest_AuthFail(t *testing.T) { + t.Skip() c := LiveClient{ parseReference: func(string, ...name.Option) (name.Reference, error) { return name.Tag{}, nil @@ -70,6 +73,7 @@ func TestGetImageDigest_AuthFail(t *testing.T) { } func TestGetImageDigest_Denied(t *testing.T) { + t.Skip() c := LiveClient{ parseReference: func(string, ...name.Option) (name.Reference, error) { return name.Tag{}, nil diff --git a/pkg/cmd/attestation/auth/host_test.go b/pkg/cmd/attestation/auth/host_test.go index 5d905bd04..88f1da09b 100644 --- a/pkg/cmd/attestation/auth/host_test.go +++ b/pkg/cmd/attestation/auth/host_test.go @@ -9,6 +9,7 @@ import ( ) func TestIsHostSupported(t *testing.T) { + t.Skip() testcases := []struct { name string expectedErr bool diff --git a/pkg/cmd/attestation/download/download_test.go b/pkg/cmd/attestation/download/download_test.go index ddcd08c92..4bdfb4e25 100644 --- a/pkg/cmd/attestation/download/download_test.go +++ b/pkg/cmd/attestation/download/download_test.go @@ -35,6 +35,7 @@ func expectedFilePath(tempDir string, digestWithAlg string) string { } func TestNewDownloadCmd(t *testing.T) { + t.Skip() testIO, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ IOStreams: testIO, @@ -190,6 +191,7 @@ func TestNewDownloadCmd(t *testing.T) { } func TestRunDownload(t *testing.T) { + t.Skip() tempDir := t.TempDir() store := &LiveStore{ outputPath: tempDir, diff --git a/pkg/cmd/attestation/download/metadata_test.go b/pkg/cmd/attestation/download/metadata_test.go index 2596e2377..6f9c868c8 100644 --- a/pkg/cmd/attestation/download/metadata_test.go +++ b/pkg/cmd/attestation/download/metadata_test.go @@ -28,6 +28,7 @@ func OnCreateMetadataFileFailure(artifactDigest string, attestationsResp []*api. } func TestCreateJSONLinesFilePath(t *testing.T) { + t.Skip() tempDir := t.TempDir() artifact, err := artifact.NewDigestedArtifact(oci.MockClient{}, "../test/data/sigstore-js-2.1.0.tgz", "sha512") require.NoError(t, err) diff --git a/pkg/cmd/attestation/download/options_test.go b/pkg/cmd/attestation/download/options_test.go index 800691d79..fa6814838 100644 --- a/pkg/cmd/attestation/download/options_test.go +++ b/pkg/cmd/attestation/download/options_test.go @@ -8,6 +8,7 @@ import ( ) func TestAreFlagsValid(t *testing.T) { + t.Skip() tests := []struct { name string limit int diff --git a/pkg/cmd/attestation/inspect/bundle_test.go b/pkg/cmd/attestation/inspect/bundle_test.go index 61b8d7bfc..c37613831 100644 --- a/pkg/cmd/attestation/inspect/bundle_test.go +++ b/pkg/cmd/attestation/inspect/bundle_test.go @@ -10,6 +10,7 @@ import ( ) func TestGetOrgAndRepo(t *testing.T) { + t.Skip() t.Run("with valid source URL", func(t *testing.T) { sourceURL := "https://github.com/github/gh-attestation" org, repo, err := getOrgAndRepo("", sourceURL) @@ -36,6 +37,7 @@ func TestGetOrgAndRepo(t *testing.T) { } func TestGetAttestationDetail(t *testing.T) { + t.Skip() bundlePath := test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json") attestations, err := verification.GetLocalAttestations(bundlePath) diff --git a/pkg/cmd/attestation/inspect/inspect_test.go b/pkg/cmd/attestation/inspect/inspect_test.go index 1e0c1305e..0f9da0396 100644 --- a/pkg/cmd/attestation/inspect/inspect_test.go +++ b/pkg/cmd/attestation/inspect/inspect_test.go @@ -31,6 +31,7 @@ var ( ) func TestNewInspectCmd(t *testing.T) { + t.Skip() testIO, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ IOStreams: testIO, @@ -88,6 +89,7 @@ func TestNewInspectCmd(t *testing.T) { } func TestRunInspect(t *testing.T) { + t.Skip() opts := Options{ BundlePath: bundlePath, Logger: io.NewTestHandler(), @@ -113,6 +115,7 @@ func TestRunInspect(t *testing.T) { } func TestJSONOutput(t *testing.T) { + t.Skip() testIO, _, out, _ := iostreams.Test() opts := Options{ BundlePath: bundlePath, diff --git a/pkg/cmd/attestation/trustedroot/trustedroot_test.go b/pkg/cmd/attestation/trustedroot/trustedroot_test.go index c4a259436..5ad4e5131 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot_test.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot_test.go @@ -22,6 +22,7 @@ import ( ) func TestNewTrustedRootCmd(t *testing.T) { + t.Skip() testIO, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ IOStreams: testIO, @@ -82,6 +83,7 @@ func TestNewTrustedRootCmd(t *testing.T) { } func TestNewTrustedRootWithTenancy(t *testing.T) { + t.Skip() testIO, _, _, _ := iostreams.Test() var testReg httpmock.Registry var metaResp = api.MetaResponse{ @@ -163,6 +165,7 @@ var newTUFErrClient tufClientInstantiator = func(o *tuf.Options) (*tuf.Client, e } func TestGetTrustedRoot(t *testing.T) { + t.Skip() mirror := "https://tuf-repo.github.com" root := test.NormalizeRelativePath("../verification/embed/tuf-repo.github.com/root.json") diff --git a/pkg/cmd/attestation/verification/attestation_test.go b/pkg/cmd/attestation/verification/attestation_test.go index 8acff0c37..166339bca 100644 --- a/pkg/cmd/attestation/verification/attestation_test.go +++ b/pkg/cmd/attestation/verification/attestation_test.go @@ -14,6 +14,7 @@ import ( ) func TestLoadBundlesFromJSONLinesFile(t *testing.T) { + t.Skip() t.Run("with original file", func(t *testing.T) { path := "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl" attestations, err := loadBundlesFromJSONLinesFile(path) @@ -43,6 +44,7 @@ func TestLoadBundlesFromJSONLinesFile(t *testing.T) { } func TestLoadBundlesFromJSONLinesFile_RejectEmptyJSONLFile(t *testing.T) { + t.Skip() // Create a temporary file emptyJSONL, err := os.CreateTemp("", "empty.jsonl") require.NoError(t, err) @@ -56,6 +58,7 @@ func TestLoadBundlesFromJSONLinesFile_RejectEmptyJSONLFile(t *testing.T) { } func TestLoadBundleFromJSONFile(t *testing.T) { + t.Skip() path := "../test/data/sigstore-js-2.1.0-bundle.json" attestations, err := loadBundleFromJSONFile(path) @@ -64,6 +67,7 @@ func TestLoadBundleFromJSONFile(t *testing.T) { } func TestGetLocalAttestations(t *testing.T) { + t.Skip() t.Run("with JSON file containing one bundle", func(t *testing.T) { path := "../test/data/sigstore-js-2.1.0-bundle.json" attestations, err := GetLocalAttestations(path) @@ -118,6 +122,7 @@ func TestGetLocalAttestations(t *testing.T) { } func TestFilterAttestations(t *testing.T) { + t.Skip() attestations := []*api.Attestation{ { Bundle: &bundle.Bundle{ diff --git a/pkg/cmd/attestation/verification/extensions_test.go b/pkg/cmd/attestation/verification/extensions_test.go index 73d808119..2e9ea1745 100644 --- a/pkg/cmd/attestation/verification/extensions_test.go +++ b/pkg/cmd/attestation/verification/extensions_test.go @@ -25,6 +25,7 @@ func createSampleResult() *AttestationProcessingResult { } func TestVerifyCertExtensions(t *testing.T) { + t.Skip() results := []*AttestationProcessingResult{createSampleResult()} certSummary := certificate.Summary{} diff --git a/pkg/cmd/attestation/verification/tuf_test.go b/pkg/cmd/attestation/verification/tuf_test.go index e8b6ecf98..dc2b36bb6 100644 --- a/pkg/cmd/attestation/verification/tuf_test.go +++ b/pkg/cmd/attestation/verification/tuf_test.go @@ -11,6 +11,7 @@ import ( ) func TestGitHubTUFOptionsNoMetadataDir(t *testing.T) { + t.Skip() os.Setenv("CODESPACES", "true") opts := GitHubTUFOptions(o.None[string]()) diff --git a/pkg/cmd/attestation/verify/options_test.go b/pkg/cmd/attestation/verify/options_test.go index bdb851e7b..3f5a79065 100644 --- a/pkg/cmd/attestation/verify/options_test.go +++ b/pkg/cmd/attestation/verify/options_test.go @@ -23,6 +23,7 @@ var baseOptions = Options{ } func TestAreFlagsValid(t *testing.T) { + t.Skip() t.Run("has invalid Repo value", func(t *testing.T) { opts := baseOptions opts.Repo = "sigstoresigstore-js" diff --git a/pkg/cmd/attestation/verify/policy_test.go b/pkg/cmd/attestation/verify/policy_test.go index ff10cad11..719119083 100644 --- a/pkg/cmd/attestation/verify/policy_test.go +++ b/pkg/cmd/attestation/verify/policy_test.go @@ -9,6 +9,7 @@ import ( ) func TestNewEnforcementCriteria(t *testing.T) { + t.Skip() artifactPath := "../test/data/sigstore-js-2.1.0.tgz" t.Run("sets SANRegex and SAN using SANRegex and SAN", func(t *testing.T) { diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index 92864f78e..e6bdd1cc8 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -17,6 +17,7 @@ import ( ) func TestVerifyIntegration(t *testing.T) { + t.Skip() logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ @@ -130,6 +131,7 @@ func TestVerifyIntegration(t *testing.T) { } func TestVerifyIntegrationCustomIssuer(t *testing.T) { + t.Skip() artifactPath := test.NormalizeRelativePath("../test/data/custom-issuer-artifact") bundlePath := test.NormalizeRelativePath("../test/data/custom-issuer.sigstore.json") @@ -203,6 +205,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { } func TestVerifyIntegrationReusableWorkflow(t *testing.T) { + t.Skip() artifactPath := test.NormalizeRelativePath("../test/data/reusable-workflow-artifact") bundlePath := test.NormalizeRelativePath("../test/data/reusable-workflow-attestation.sigstore.json") @@ -295,6 +298,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { } func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { + t.Skip() artifactPath := test.NormalizeRelativePath("../test/data/reusable-workflow-artifact") bundlePath := test.NormalizeRelativePath("../test/data/reusable-workflow-attestation.sigstore.json") diff --git a/pkg/cmd/attestation/verify/verify_test.go b/pkg/cmd/attestation/verify/verify_test.go index 092a009d8..40635c220 100644 --- a/pkg/cmd/attestation/verify/verify_test.go +++ b/pkg/cmd/attestation/verify/verify_test.go @@ -34,6 +34,7 @@ var ( ) func TestNewVerifyCmd(t *testing.T) { + t.Skip() testIO, _, _, _ := iostreams.Test() var testReg httpmock.Registry var metaResp = api.MetaResponse{ @@ -315,6 +316,7 @@ func TestNewVerifyCmd(t *testing.T) { } func TestVerifyCmdAuthChecks(t *testing.T) { + t.Skip() f := &cmdutil.Factory{} t.Run("by default auth check is required", func(t *testing.T) { @@ -345,6 +347,7 @@ func TestVerifyCmdAuthChecks(t *testing.T) { } func TestJSONOutput(t *testing.T) { + t.Skip() testIO, _, out, _ := iostreams.Test() opts := Options{ ArtifactPath: artifactPath, @@ -368,6 +371,7 @@ func TestJSONOutput(t *testing.T) { } func TestRunVerify(t *testing.T) { + t.Skip() logger := io.NewTestHandler() publicGoodOpts := Options{ From 7b20ee5549454db7007a6314e002d2e5ab683eca Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Wed, 9 Apr 2025 03:58:20 -0600 Subject: [PATCH 065/147] Merge pull request #10749 from malancas/update-to-sigstore-go-v0.7.1 Update github.com/sigstore/sigstore-go to 0.7.1 and fix breaking function change --- go.mod | 65 +++-- go.sum | 326 +++++++++++++------------- pkg/cmd/attestation/test/data/data.go | 10 +- 3 files changed, 196 insertions(+), 205 deletions(-) diff --git a/go.mod b/go.mod index bea712a2d..c24bd57cc 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/gabriel-vasile/mimetype v1.4.8 github.com/gdamore/tcell/v2 v2.5.4 github.com/golang/snappy v0.0.4 - github.com/google/go-cmp v0.6.0 + github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.20.3 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/websocket v1.5.3 @@ -40,18 +40,18 @@ require ( github.com/opentracing/opentracing-go v1.2.0 github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc - github.com/sigstore/protobuf-specs v0.3.3 - github.com/sigstore/sigstore-go v0.7.0 - github.com/spf13/cobra v1.8.1 + github.com/sigstore/protobuf-specs v0.4.1 + github.com/sigstore/sigstore-go v0.7.1 + github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 github.com/zalando/go-keyring v0.2.5 - golang.org/x/crypto v0.35.0 - golang.org/x/sync v0.12.0 - golang.org/x/term v0.30.0 - golang.org/x/text v0.23.0 - google.golang.org/grpc v1.69.4 - google.golang.org/protobuf v1.36.5 + golang.org/x/crypto v0.37.0 + golang.org/x/sync v0.13.0 + golang.org/x/term v0.31.0 + golang.org/x/text v0.24.0 + google.golang.org/grpc v1.71.0 + google.golang.org/protobuf v1.36.6 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -83,29 +83,29 @@ require ( github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.8.2 // indirect github.com/fatih/color v1.16.0 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gdamore/encoding v1.0.0 // indirect github.com/go-chi/chi v4.1.2+incompatible // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.23.0 // indirect - github.com/go-openapi/errors v0.22.0 // indirect + github.com/go-openapi/errors v0.22.1 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/loads v0.22.0 // indirect github.com/go-openapi/runtime v0.28.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/strfmt v0.23.0 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/validate v0.24.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/certificate-transparency-go v1.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/in-toto/in-toto-golang v0.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -116,8 +116,7 @@ require ( github.com/klauspost/compress v1.17.11 // indirect github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mailru/easyjson v0.7.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect @@ -129,27 +128,26 @@ require ( github.com/oklog/ulid v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rodaine/table v1.0.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sassoftware/relic v7.2.1+incompatible // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect - github.com/sigstore/rekor v1.3.8 // indirect - github.com/sigstore/sigstore v1.8.12 // indirect - github.com/sigstore/timestamp-authority v1.2.4 // indirect + github.com/sigstore/rekor v1.3.9 // indirect + github.com/sigstore/sigstore v1.9.1 // indirect + github.com/sigstore/timestamp-authority v1.2.5 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.7.0 // indirect - github.com/spf13/viper v1.19.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/viper v1.20.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/theupdateframework/go-tuf v0.7.0 // indirect @@ -163,18 +161,17 @@ require ( github.com/yuin/goldmark-emoji v1.0.5 // indirect go.mongodb.org/mongo-driver v1.14.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel v1.33.0 // indirect - go.opentelemetry.io/otel/metric v1.33.0 // indirect - go.opentelemetry.io/otel/trace v1.33.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect - golang.org/x/mod v0.22.0 // indirect - golang.org/x/net v0.36.0 // indirect - golang.org/x/sys v0.31.0 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.32.0 // indirect golang.org/x/tools v0.29.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect k8s.io/klog/v2 v2.130.1 // indirect ) diff --git a/go.sum b/go.sum index 2b5a31212..a0f64b334 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,17 @@ -cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= -cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= -cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs= -cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q= -cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU= -cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8= -cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= +cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME= +cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc= +cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= +cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= +cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= +cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= -cloud.google.com/go/iam v1.2.2 h1:ozUSofHUGf/F4tCNy/mu9tHLTaxZFLOUiKzjcgWHGIA= -cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY= -cloud.google.com/go/kms v1.20.4 h1:CJ0hMpOg1ANN9tx/a/GPJ+Uxudy8k6f3fvGFuTHiE5A= -cloud.google.com/go/kms v1.20.4/go.mod h1:gPLsp1r4FblUgBYPOcvI/bUPpdMg2Jm1ZVKU4tQUfcc= -cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0MK+hc= -cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI= +cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM= +cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM= +cloud.google.com/go/kms v1.21.1 h1:r1Auo+jlfJSf8B7mUnVw5K0fI7jWyoUy65bV53VjKyk= +cloud.google.com/go/kms v1.21.1/go.mod h1:s0wCyByc9LjTdCjG88toVs70U9W+cc6RKFc8zAqX7nE= +cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q= +cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= @@ -21,18 +20,18 @@ github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjq github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 h1:B/dfvscEQtew9dVuoxqxrUKKv8Ih2f55PydknDamU+g= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0/go.mod h1:fiPSssYvltE08HJchL04dOy+RD4hgrjph0cwGGMntdI= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0 h1:7rKG7UmnrxX4N53TFhkYqjc+kVUZuw0fL8I3Fh+Ld9E= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0/go.mod h1:Wjo+24QJVhhl/L7jy6w9yzFF2yDOf3cKECAa8ecf9vE= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 h1:eXnN9kaS8TiDwXjoie3hMRLuwdUBUMW9KRgOqB3mCaw= -github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0/go.mod h1:XIpam8wumeZ5rVMuhdDQLMfIPDf1WO3IzrCRO3e3e3o= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1 h1:gUDtaZk8heteyfdmv+pcfHvhR9llnh7c7GMwZ8RVG04= -github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= +github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4= +github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -53,36 +52,36 @@ github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4u github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= -github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.32.8 h1:cZV+NUS/eGxKXMtmyhtYPJ7Z4YLoI/V8bkTdRZfYhGo= -github.com/aws/aws-sdk-go-v2 v1.32.8/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= -github.com/aws/aws-sdk-go-v2/config v1.28.10 h1:fKODZHfqQu06pCzR69KJ3GuttraRJkhlC8g80RZ0Dfg= -github.com/aws/aws-sdk-go-v2/config v1.28.10/go.mod h1:PvdxRYZ5Um9QMq9PQ0zHHNdtKK+he2NHtFCUFMXWXeg= -github.com/aws/aws-sdk-go-v2/credentials v1.17.51 h1:F/9Sm6Y6k4LqDesZDPJCLxQGXNNHd/ZtJiWd0lCZKRk= -github.com/aws/aws-sdk-go-v2/credentials v1.17.51/go.mod h1:TKbzCHm43AoPyA+iLGGcruXd4AFhF8tOmLex2R9jWNQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23 h1:IBAoD/1d8A8/1aA8g4MBVtTRHhXRiNAgwdbo/xRM2DI= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23/go.mod h1:vfENuCM7dofkgKpYzuzf1VT1UKkA/YL3qanfBn7HCaA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27 h1:jSJjSBzw8VDIbWv+mmvBSP8ezsztMYJGH+eKqi9AmNs= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27/go.mod h1:/DAhLbFRgwhmvJdOfSm+WwikZrCuUJiA4WgJG0fTNSw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27 h1:l+X4K77Dui85pIj5foXDhPlnqcNRG2QUyvca300lXh8= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27/go.mod h1:KvZXSFEXm6x84yE8qffKvT3x8J5clWnVFXphpohhzJ8= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8 h1:cWno7lefSH6Pp+mSznagKCgfDGeZRin66UvYUqAkyeA= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8/go.mod h1:tPD+VjU3ABTBoEJ3nctu5Nyg4P4yjqSH5bJGGkY4+XE= -github.com/aws/aws-sdk-go-v2/service/kms v1.37.8 h1:KbLZjYqhQ9hyB4HwXiheiflTlYQa0+Fz0Ms/rh5f3mk= -github.com/aws/aws-sdk-go-v2/service/kms v1.37.8/go.mod h1:ANs9kBhK4Ghj9z1W+bsr3WsNaPF71qkgd6eE6Ekol/Y= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.9 h1:YqtxripbjWb2QLyzRK9pByfEDvgg95gpC2AyDq4hFE8= -github.com/aws/aws-sdk-go-v2/service/sso v1.24.9/go.mod h1:lV8iQpg6OLOfBnqbGMBKYjilBlf633qwHnBEiMSPoHY= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8 h1:6dBT1Lz8fK11m22R+AqfRsFn8320K0T5DTGxxOQBSMw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8/go.mod h1:/kiBvRQXBc6xeJTYzhSdGvJ5vm1tjaDEjH+MSeRJnlY= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 h1:VwhTrsTuVn52an4mXx29PqRzs2Dvu921NpGk7y43tAM= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.6/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc= -github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= -github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk= +github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= +github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2/config v1.29.10 h1:yNjgjiGBp4GgaJrGythyBXg2wAs+Im9fSWIUwvi1CAc= +github.com/aws/aws-sdk-go-v2/config v1.29.10/go.mod h1:A0mbLXSdtob/2t59n1X0iMkPQ5d+YzYZB4rwu7SZ7aA= +github.com/aws/aws-sdk-go-v2/credentials v1.17.63 h1:rv1V3kIJ14pdmTu01hwcMJ0WAERensSiD9rEWEBb1Tk= +github.com/aws/aws-sdk-go-v2/credentials v1.17.63/go.mod h1:EJj+yDf0txT26Ulo0VWTavBl31hOsaeuMxIHu2m0suY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= +github.com/aws/aws-sdk-go-v2/service/kms v1.38.1 h1:tecq7+mAav5byF+Mr+iONJnCBf4B4gon8RSp4BrweSc= +github.com/aws/aws-sdk-go-v2/service/kms v1.38.1/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.1 h1:8JdC7Gr9NROg1Rusk25IcZeTO59zLxsKgE0gkh5O6h0= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.1/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.2 h1:wK8O+j2dOolmpNVY1EWIbLgxrGCHJKVPm08Hv/u80M8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.2/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 h1:PZV5W8yk4OtH1JAuhV2PXwwO9v5G5Aoj+eMCn4T+1Kc= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= +github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= +github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -131,7 +130,6 @@ github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUo github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -167,8 +165,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= @@ -177,8 +175,6 @@ github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/ github.com/gdamore/tcell/v2 v2.5.4/go.mod h1:dZgRy5v4iMobMEcWNYBtREnDZAT9DYmfqIkrgEMxLyw= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= -github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= -github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -188,8 +184,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= -github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= -github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/errors v0.22.1 h1:kslMRRnK7NCb/CvR1q1VWuEQCEIsBGn5GgKD9e+HYhU= +github.com/go-openapi/errors v0.22.1/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -202,32 +198,34 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/certificate-transparency-go v1.3.1 h1:akbcTfQg0iZlANZLn0L9xOeWtyCIdeoYhKrqi5iH3Go= github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI= github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= -github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w= @@ -236,8 +234,8 @@ github.com/google/trillian v1.7.1 h1:+zX8jLM3524bAMPS+VxaDIDgsMv3/ty6DuLWerHXcek github.com/google/trillian v1.7.1/go.mod h1:E1UMAHqpZCA8AQdrKdWmHmtUfSeiD0sDWD1cv00Xa+c= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= -github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= +github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= +github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -265,13 +263,12 @@ github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0S github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw= github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA= -github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= +github.com/hashicorp/vault/api v1.16.0 h1:nbEYGJiAPGzT9U4oWgaaB0g+Rj8E59QuHKyA5LhwQN4= +github.com/hashicorp/vault/api v1.16.0/go.mod h1:KhuUhzOD8lDSk29AtzNjgAu2kxRA9jL9NAbkFlqvkBA= github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -330,10 +327,8 @@ github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec h1:2tTW6cDth2T github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec/go.mod h1:TmwEoGCwIti7BCeJ9hescZgRtatxRE+A72pCoPfmcfk= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -378,8 +373,8 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -387,12 +382,12 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= +github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d h1:jKIUJdMcIVGOSHi6LSqJqw9RqblyblE2ZrHvFbWR3S0= @@ -409,10 +404,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= @@ -429,51 +422,44 @@ github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc h1:vH0NQbIDk+mJL github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= -github.com/sigstore/protobuf-specs v0.3.3 h1:RMZQgXTD/pF7KW6b5NaRLYxFYZ/wzx44PQFXN2PEo5g= -github.com/sigstore/protobuf-specs v0.3.3/go.mod h1:vIhZ6Uor1a38+wvRrKcqL2PtYNlgoIW9lhzYzkyy4EU= -github.com/sigstore/rekor v1.3.8 h1:B8kJI8mpSIXova4Jxa6vXdJyysRxFGsEsLKBDl0rRjA= -github.com/sigstore/rekor v1.3.8/go.mod h1:/dHFYKSuxEygfDRnEwyJ+ZD6qoVYNXQdi1mJrKvKWsI= -github.com/sigstore/sigstore v1.8.12 h1:S8xMVZbE2z9ZBuQUEG737pxdLjnbOIcFi5v9UFfkJFc= -github.com/sigstore/sigstore v1.8.12/go.mod h1:+PYQAa8rfw0QdPpBcT+Gl3egKD9c+TUgAlF12H3Nmjo= -github.com/sigstore/sigstore-go v0.7.0 h1:bIGPc2IbnbxnzlqQcKlh1o96bxVJ4yRElpP1gHrOH48= -github.com/sigstore/sigstore-go v0.7.0/go.mod h1:4RrCK+i+jhx7lyOG2Vgef0/kFLbKlDI1hrioUYvkxxA= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.12 h1:EC3UmIaa7nV9sCgSpVevmvgvTYTkMqyrRbj5ojPp7tE= -github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.12/go.mod h1:aw60vs3crnQdM/DYH+yF2P0MVKtItwAX34nuaMrY7Lk= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.12 h1:FPpliDTywSy0woLHMAdmTSZ5IS/lVBZ0dY0I+2HmnSY= -github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.12/go.mod h1:NkPiz4XA0JcBSXzJUrjMj7Xi7oSTew1Ip3Zmt56mHlw= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.12 h1:kweBChR6M9FEvmxN3BMEcl7SNnwxTwKF7THYFKLOE5U= -github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.12/go.mod h1:6+d+A6oYt1W5OgtzgEVb21V7tAZ/C2Ihtzc5MNJbayY= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.12 h1:jvY1B9bjP+tKzdKDyuq5K7O19CG2IKzGJNTy5tuL2Gs= -github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.12/go.mod h1:2uEeOb8xE2RC6OvzxKux1wkS39Zv8gA27z92m49xUTc= -github.com/sigstore/timestamp-authority v1.2.4 h1:RjXZxOWorEiem/uSr0pFHVtQpyzpcFxgugo5jVqm3mw= -github.com/sigstore/timestamp-authority v1.2.4/go.mod h1:ExrbobKdEuwuBptZIiKp1IaVBRiUeKbiuSyZTO8Okik= +github.com/sigstore/protobuf-specs v0.4.1 h1:5SsMqZbdkcO/DNHudaxuCUEjj6x29tS2Xby1BxGU7Zc= +github.com/sigstore/protobuf-specs v0.4.1/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= +github.com/sigstore/rekor v1.3.9 h1:sUjRpKVh/hhgqGMs0t+TubgYsksArZ6poLEC3MsGAzU= +github.com/sigstore/rekor v1.3.9/go.mod h1:xThNUhm6eNEmkJ/SiU/FVU7pLY2f380fSDZFsdDWlcM= +github.com/sigstore/sigstore v1.9.1 h1:bNMsfFATsMPaagcf+uppLk4C9rQZ2dh5ysmCxQBYWaw= +github.com/sigstore/sigstore v1.9.1/go.mod h1:zUoATYzR1J3rLNp3jmp4fzIJtWdhC3ZM6MnpcBtnsE4= +github.com/sigstore/sigstore-go v0.7.1 h1:lyzi3AjO6+BHc5zCf9fniycqPYOt3RaC08M/FRmQhVY= +github.com/sigstore/sigstore-go v0.7.1/go.mod h1:AIRj4I3LC82qd07VFm3T2zXYiddxeBV1k/eoS8nTz0E= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.1 h1:/YcNq687WnXpIRXl04nLfJX741G4iW+w+7Nem2Zy0f4= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.1/go.mod h1:ApL9RpKsi7gkSYN0bMNdm/3jZ9EefxMmfYHfUmq2ZYM= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.1 h1:FnusXyTIInnwfIOzzl5PFilRm1I97dxMSOcCkZBu9Kc= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.1/go.mod h1:d5m5LOa/69a+t2YC9pDPwS1n2i/PhqB4cUKbpVDlKKE= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.1 h1:LFiYK1DEWQ6Hf/nroFzBMM+s5rVSjVL45Alpb5Ctl5A= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.1/go.mod h1:GFyFmDsE2wDuIHZD+4+JErGpA0S4zJsKNz5l2JVJd8s= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.1 h1:sIW6xe4yU5eIMH8fve2C78d+r29KmHnIb+7po+80bsY= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.1/go.mod h1:3pNf99GnK9eu3XUa5ebHzgEQSVYf9hqAoPFwbwD6O6M= +github.com/sigstore/timestamp-authority v1.2.5 h1:W22JmwRv1Salr/NFFuP7iJuhytcZszQjldoB8GiEdnw= +github.com/sigstore/timestamp-authority v1.2.5/go.mod h1:gWPKWq4HMWgPCETre0AakgBzcr9DRqHrsgbrRqsigOs= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= -github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -484,6 +470,12 @@ github.com/theupdateframework/go-tuf/v2 v2.0.2 h1:PyNnjV9BJNzN1ZE6BcWK+5JbF+if37 github.com/theupdateframework/go-tuf/v2 v2.0.2/go.mod h1:baB22nBHeHBCeuGZcIlctNq4P61PcOdyARlplg5xmLA= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= +github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI= +github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= +github.com/tink-crypto/tink-go/v2 v2.3.0 h1:4/TA0lw0lA/iVKBL9f8R5eP7397bfc4antAMXF5JRhs= +github.com/tink-crypto/tink-go/v2 v2.3.0/go.mod h1:kfPOtXIadHlekBTeBtJrHWqoGL+Fm3JQg0wtltPuxLU= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= @@ -504,22 +496,22 @@ go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= -go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= -go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= -go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= -go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= -go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= -go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= -go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= -go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= -go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= -go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= -go.step.sm/crypto v0.57.0 h1:YjoRQDaJYAxHLVwjst0Bl0xcnoKzVwuHCJtEo2VSHYU= -go.step.sm/crypto v0.57.0/go.mod h1:+Lwp5gOVPaTa3H/Ul/TzGbxQPXZZcKIUGMS0lG6n9Go= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.step.sm/crypto v0.60.0 h1:UgSw8DFG5xUOGB3GUID17UA32G4j1iNQ4qoMhBmsVFw= +go.step.sm/crypto v0.60.0/go.mod h1:Ep83Lv818L4gV0vhFTdPWRKnL6/5fRMpi8SaoP5ArSw= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -528,24 +520,24 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= -golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= -golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= -golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= -golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -555,46 +547,44 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.216.0 h1:xnEHy+xWFrtYInWPy8OdGFsyIfWJjtVnO39g7pz2BFY= -google.golang.org/api v0.216.0/go.mod h1:K9wzQMvWi47Z9IU7OgdOofvZuw75Ge3PPITImZR/UyI= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc= -google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= -google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= -google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= -google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/api v0.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc= +google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= +google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= +google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/cmd/attestation/test/data/data.go b/pkg/cmd/attestation/test/data/data.go index b33efaa28..ef3c35c20 100644 --- a/pkg/cmd/attestation/test/data/data.go +++ b/pkg/cmd/attestation/test/data/data.go @@ -5,13 +5,17 @@ import ( "testing" "github.com/sigstore/sigstore-go/pkg/bundle" - sgData "github.com/sigstore/sigstore-go/pkg/testing/data" ) //go:embed sigstore-js-2.1.0-bundle.json var SigstoreBundleRaw []byte -// SigstoreBundle returns a test *sigstore.Bundle +// SigstoreBundle returns a test sigstore-go bundle.Bundle func SigstoreBundle(t *testing.T) *bundle.Bundle { - return sgData.TestBundle(t, SigstoreBundleRaw) + b := &bundle.Bundle{} + err := b.UnmarshalJSON(SigstoreBundleRaw) + if err != nil { + t.Fatalf("failed to unmarshal sigstore bundle: %v", err) + } + return b } From 139e82c68cfaccf697650b2f17fa406cf10bc3cd Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Wed, 9 Apr 2025 07:23:17 -0600 Subject: [PATCH 066/147] Revert "temporarily skip non-failing tests" This reverts commit de8778797f85e9db7cca3bfca3bb9e11cd07b141. --- pkg/cmd/attestation/api/client_test.go | 18 ------------------ .../artifact/artifact_posix_test.go | 1 - .../artifact/artifact_windows_test.go | 1 - .../attestation/artifact/digest/digest_test.go | 2 -- pkg/cmd/attestation/artifact/image_test.go | 3 --- .../attestation/artifact/oci/client_test.go | 4 ---- pkg/cmd/attestation/auth/host_test.go | 1 - pkg/cmd/attestation/download/download_test.go | 2 -- pkg/cmd/attestation/download/metadata_test.go | 1 - pkg/cmd/attestation/download/options_test.go | 1 - pkg/cmd/attestation/inspect/bundle_test.go | 2 -- pkg/cmd/attestation/inspect/inspect_test.go | 3 --- .../trustedroot/trustedroot_test.go | 3 --- .../verification/attestation_test.go | 5 ----- .../verification/extensions_test.go | 1 - pkg/cmd/attestation/verification/tuf_test.go | 1 - pkg/cmd/attestation/verify/options_test.go | 1 - pkg/cmd/attestation/verify/policy_test.go | 1 - .../verify/verify_integration_test.go | 4 ---- pkg/cmd/attestation/verify/verify_test.go | 4 ---- 20 files changed, 59 deletions(-) diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go index 0e0827295..787408a4e 100644 --- a/pkg/cmd/attestation/api/client_test.go +++ b/pkg/cmd/attestation/api/client_test.go @@ -43,7 +43,6 @@ func NewClientWithMockGHClient(hasNextPage bool) Client { } func TestGetByDigest(t *testing.T) { - t.Skip() c := NewClientWithMockGHClient(false) attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit) require.NoError(t, err) @@ -61,7 +60,6 @@ func TestGetByDigest(t *testing.T) { } func TestGetByDigestGreaterThanLimit(t *testing.T) { - t.Skip() c := NewClientWithMockGHClient(false) limit := 3 @@ -82,7 +80,6 @@ func TestGetByDigestGreaterThanLimit(t *testing.T) { } func TestGetByDigestWithNextPage(t *testing.T) { - t.Skip() c := NewClientWithMockGHClient(true) attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit) require.NoError(t, err) @@ -100,7 +97,6 @@ func TestGetByDigestWithNextPage(t *testing.T) { } func TestGetByDigestGreaterThanLimitWithNextPage(t *testing.T) { - t.Skip() c := NewClientWithMockGHClient(true) limit := 7 @@ -121,7 +117,6 @@ func TestGetByDigestGreaterThanLimitWithNextPage(t *testing.T) { } func TestGetByDigest_NoAttestationsFound(t *testing.T) { - t.Skip() fetcher := mockDataGenerator{ NumAttestations: 5, } @@ -147,7 +142,6 @@ func TestGetByDigest_NoAttestationsFound(t *testing.T) { } func TestGetByDigest_Error(t *testing.T) { - t.Skip() fetcher := mockDataGenerator{ NumAttestations: 5, } @@ -169,7 +163,6 @@ func TestGetByDigest_Error(t *testing.T) { } func TestFetchBundleFromAttestations_BundleURL(t *testing.T) { - t.Skip() httpClient := &mockHttpClient{} client := LiveClient{ httpClient: httpClient, @@ -187,7 +180,6 @@ func TestFetchBundleFromAttestations_BundleURL(t *testing.T) { } func TestFetchBundleFromAttestations_MissingBundleAndBundleURLFields(t *testing.T) { - t.Skip() httpClient := &mockHttpClient{} client := LiveClient{ httpClient: httpClient, @@ -204,7 +196,6 @@ func TestFetchBundleFromAttestations_MissingBundleAndBundleURLFields(t *testing. } func TestFetchBundleFromAttestations_FailOnTheSecondAttestation(t *testing.T) { - t.Skip() mockHTTPClient := &failAfterNCallsHttpClient{ // the initial HTTP request will succeed, which returns a bundle for the first attestation // all following HTTP requests will fail, which means the function fails to fetch a bundle @@ -227,7 +218,6 @@ func TestFetchBundleFromAttestations_FailOnTheSecondAttestation(t *testing.T) { } func TestFetchBundleFromAttestations_FailAfterRetrying(t *testing.T) { - t.Skip() mockHTTPClient := &reqFailHttpClient{} c := &LiveClient{ @@ -244,7 +234,6 @@ func TestFetchBundleFromAttestations_FailAfterRetrying(t *testing.T) { } func TestFetchBundleFromAttestations_FallbackToBundleField(t *testing.T) { - t.Skip() mockHTTPClient := &mockHttpClient{} c := &LiveClient{ @@ -263,7 +252,6 @@ func TestFetchBundleFromAttestations_FallbackToBundleField(t *testing.T) { // getBundle successfully fetches a bundle on the first HTTP request attempt func TestGetBundle(t *testing.T) { - t.Skip() mockHTTPClient := &mockHttpClient{} c := &LiveClient{ @@ -280,7 +268,6 @@ func TestGetBundle(t *testing.T) { // getBundle retries successfully when the initial HTTP request returns // a 5XX status code func TestGetBundle_SuccessfulRetry(t *testing.T) { - t.Skip() mockHTTPClient := &failAfterNCallsHttpClient{ FailOnCallN: 1, FailOnAllSubsequentCalls: false, @@ -299,7 +286,6 @@ func TestGetBundle_SuccessfulRetry(t *testing.T) { // getBundle does not retry when the function fails with a permanent backoff error condition func TestGetBundle_PermanentBackoffFail(t *testing.T) { - t.Skip() mockHTTPClient := &invalidBundleClient{} c := &LiveClient{ httpClient: mockHTTPClient, @@ -316,7 +302,6 @@ func TestGetBundle_PermanentBackoffFail(t *testing.T) { // getBundle retries when the HTTP request fails func TestGetBundle_RequestFail(t *testing.T) { - t.Skip() mockHTTPClient := &reqFailHttpClient{} c := &LiveClient{ @@ -331,7 +316,6 @@ func TestGetBundle_RequestFail(t *testing.T) { } func TestGetTrustDomain(t *testing.T) { - t.Skip() fetcher := mockMetaGenerator{ TrustDomain: "foo", } @@ -364,7 +348,6 @@ func TestGetTrustDomain(t *testing.T) { } func TestGetAttestationsRetries(t *testing.T) { - t.Skip() getAttestationRetryInterval = 0 fetcher := mockDataGenerator{ @@ -405,7 +388,6 @@ func TestGetAttestationsRetries(t *testing.T) { // test total retries func TestGetAttestationsMaxRetries(t *testing.T) { - t.Skip() getAttestationRetryInterval = 0 fetcher := mockDataGenerator{ diff --git a/pkg/cmd/attestation/artifact/artifact_posix_test.go b/pkg/cmd/attestation/artifact/artifact_posix_test.go index 2fca69cbb..31e9cb7e7 100644 --- a/pkg/cmd/attestation/artifact/artifact_posix_test.go +++ b/pkg/cmd/attestation/artifact/artifact_posix_test.go @@ -10,7 +10,6 @@ import ( ) func TestNormalizeReference(t *testing.T) { - t.Skip() testCases := []struct { name string reference string diff --git a/pkg/cmd/attestation/artifact/artifact_windows_test.go b/pkg/cmd/attestation/artifact/artifact_windows_test.go index 4648be0a5..46995f226 100644 --- a/pkg/cmd/attestation/artifact/artifact_windows_test.go +++ b/pkg/cmd/attestation/artifact/artifact_windows_test.go @@ -10,7 +10,6 @@ import ( ) func TestNormalizeReference(t *testing.T) { - t.Skip() testCases := []struct { name string reference string diff --git a/pkg/cmd/attestation/artifact/digest/digest_test.go b/pkg/cmd/attestation/artifact/digest/digest_test.go index 2fb7727f5..bcfd2c1ac 100644 --- a/pkg/cmd/attestation/artifact/digest/digest_test.go +++ b/pkg/cmd/attestation/artifact/digest/digest_test.go @@ -9,7 +9,6 @@ import ( ) func TestArtifactDigestWithAlgorithm(t *testing.T) { - t.Skip() testString := "deadbeef" sha512TestDigest := "113a3bc783d851fc0373214b19ea7be9fa3de541ecb9fe026d52c603e8ea19c174cc0e9705f8b90d312212c0c3a6d8453ddfb3e3141409cf4bedc8ef033590b4" sha256TestDigest := "2baf1f40105d9501fe319a8ec463fdf4325a2a5df445adf3f572f626253678c9" @@ -37,7 +36,6 @@ func TestArtifactDigestWithAlgorithm(t *testing.T) { } func TestValidDigestAlgorithms(t *testing.T) { - t.Skip() t.Run("includes sha256", func(t *testing.T) { assert.Contains(t, ValidDigestAlgorithms(), "sha256") }) diff --git a/pkg/cmd/attestation/artifact/image_test.go b/pkg/cmd/attestation/artifact/image_test.go index ad0c1f406..5ea5f9a37 100644 --- a/pkg/cmd/attestation/artifact/image_test.go +++ b/pkg/cmd/attestation/artifact/image_test.go @@ -9,7 +9,6 @@ import ( ) func TestDigestContainerImageArtifact(t *testing.T) { - t.Skip() expectedDigest := "1234567890abcdef" client := oci.MockClient{} url := "example.com/repo:tag" @@ -21,7 +20,6 @@ func TestDigestContainerImageArtifact(t *testing.T) { } func TestParseImageRefFailure(t *testing.T) { - t.Skip() client := oci.ReferenceFailClient{} url := "example.com/repo:tag" _, err := digestContainerImageArtifact(url, client) @@ -29,7 +27,6 @@ func TestParseImageRefFailure(t *testing.T) { } func TestFetchImageFailure(t *testing.T) { - t.Skip() testcase := []struct { name string client oci.Client diff --git a/pkg/cmd/attestation/artifact/oci/client_test.go b/pkg/cmd/attestation/artifact/oci/client_test.go index 73cf9b42d..a46533366 100644 --- a/pkg/cmd/attestation/artifact/oci/client_test.go +++ b/pkg/cmd/attestation/artifact/oci/client_test.go @@ -13,7 +13,6 @@ import ( ) func TestGetImageDigest_Success(t *testing.T) { - t.Skip() expectedDigest := v1.Hash{ Hex: "1234567890abcdef", Algorithm: "sha256", @@ -38,7 +37,6 @@ func TestGetImageDigest_Success(t *testing.T) { } func TestGetImageDigest_ReferenceFail(t *testing.T) { - t.Skip() c := LiveClient{ parseReference: func(string, ...name.Option) (name.Reference, error) { return nil, fmt.Errorf("failed to parse reference") @@ -55,7 +53,6 @@ func TestGetImageDigest_ReferenceFail(t *testing.T) { } func TestGetImageDigest_AuthFail(t *testing.T) { - t.Skip() c := LiveClient{ parseReference: func(string, ...name.Option) (name.Reference, error) { return name.Tag{}, nil @@ -73,7 +70,6 @@ func TestGetImageDigest_AuthFail(t *testing.T) { } func TestGetImageDigest_Denied(t *testing.T) { - t.Skip() c := LiveClient{ parseReference: func(string, ...name.Option) (name.Reference, error) { return name.Tag{}, nil diff --git a/pkg/cmd/attestation/auth/host_test.go b/pkg/cmd/attestation/auth/host_test.go index 88f1da09b..5d905bd04 100644 --- a/pkg/cmd/attestation/auth/host_test.go +++ b/pkg/cmd/attestation/auth/host_test.go @@ -9,7 +9,6 @@ import ( ) func TestIsHostSupported(t *testing.T) { - t.Skip() testcases := []struct { name string expectedErr bool diff --git a/pkg/cmd/attestation/download/download_test.go b/pkg/cmd/attestation/download/download_test.go index 4bdfb4e25..ddcd08c92 100644 --- a/pkg/cmd/attestation/download/download_test.go +++ b/pkg/cmd/attestation/download/download_test.go @@ -35,7 +35,6 @@ func expectedFilePath(tempDir string, digestWithAlg string) string { } func TestNewDownloadCmd(t *testing.T) { - t.Skip() testIO, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ IOStreams: testIO, @@ -191,7 +190,6 @@ func TestNewDownloadCmd(t *testing.T) { } func TestRunDownload(t *testing.T) { - t.Skip() tempDir := t.TempDir() store := &LiveStore{ outputPath: tempDir, diff --git a/pkg/cmd/attestation/download/metadata_test.go b/pkg/cmd/attestation/download/metadata_test.go index 6f9c868c8..2596e2377 100644 --- a/pkg/cmd/attestation/download/metadata_test.go +++ b/pkg/cmd/attestation/download/metadata_test.go @@ -28,7 +28,6 @@ func OnCreateMetadataFileFailure(artifactDigest string, attestationsResp []*api. } func TestCreateJSONLinesFilePath(t *testing.T) { - t.Skip() tempDir := t.TempDir() artifact, err := artifact.NewDigestedArtifact(oci.MockClient{}, "../test/data/sigstore-js-2.1.0.tgz", "sha512") require.NoError(t, err) diff --git a/pkg/cmd/attestation/download/options_test.go b/pkg/cmd/attestation/download/options_test.go index fa6814838..800691d79 100644 --- a/pkg/cmd/attestation/download/options_test.go +++ b/pkg/cmd/attestation/download/options_test.go @@ -8,7 +8,6 @@ import ( ) func TestAreFlagsValid(t *testing.T) { - t.Skip() tests := []struct { name string limit int diff --git a/pkg/cmd/attestation/inspect/bundle_test.go b/pkg/cmd/attestation/inspect/bundle_test.go index c37613831..61b8d7bfc 100644 --- a/pkg/cmd/attestation/inspect/bundle_test.go +++ b/pkg/cmd/attestation/inspect/bundle_test.go @@ -10,7 +10,6 @@ import ( ) func TestGetOrgAndRepo(t *testing.T) { - t.Skip() t.Run("with valid source URL", func(t *testing.T) { sourceURL := "https://github.com/github/gh-attestation" org, repo, err := getOrgAndRepo("", sourceURL) @@ -37,7 +36,6 @@ func TestGetOrgAndRepo(t *testing.T) { } func TestGetAttestationDetail(t *testing.T) { - t.Skip() bundlePath := test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json") attestations, err := verification.GetLocalAttestations(bundlePath) diff --git a/pkg/cmd/attestation/inspect/inspect_test.go b/pkg/cmd/attestation/inspect/inspect_test.go index 0f9da0396..1e0c1305e 100644 --- a/pkg/cmd/attestation/inspect/inspect_test.go +++ b/pkg/cmd/attestation/inspect/inspect_test.go @@ -31,7 +31,6 @@ var ( ) func TestNewInspectCmd(t *testing.T) { - t.Skip() testIO, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ IOStreams: testIO, @@ -89,7 +88,6 @@ func TestNewInspectCmd(t *testing.T) { } func TestRunInspect(t *testing.T) { - t.Skip() opts := Options{ BundlePath: bundlePath, Logger: io.NewTestHandler(), @@ -115,7 +113,6 @@ func TestRunInspect(t *testing.T) { } func TestJSONOutput(t *testing.T) { - t.Skip() testIO, _, out, _ := iostreams.Test() opts := Options{ BundlePath: bundlePath, diff --git a/pkg/cmd/attestation/trustedroot/trustedroot_test.go b/pkg/cmd/attestation/trustedroot/trustedroot_test.go index 5ad4e5131..c4a259436 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot_test.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot_test.go @@ -22,7 +22,6 @@ import ( ) func TestNewTrustedRootCmd(t *testing.T) { - t.Skip() testIO, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ IOStreams: testIO, @@ -83,7 +82,6 @@ func TestNewTrustedRootCmd(t *testing.T) { } func TestNewTrustedRootWithTenancy(t *testing.T) { - t.Skip() testIO, _, _, _ := iostreams.Test() var testReg httpmock.Registry var metaResp = api.MetaResponse{ @@ -165,7 +163,6 @@ var newTUFErrClient tufClientInstantiator = func(o *tuf.Options) (*tuf.Client, e } func TestGetTrustedRoot(t *testing.T) { - t.Skip() mirror := "https://tuf-repo.github.com" root := test.NormalizeRelativePath("../verification/embed/tuf-repo.github.com/root.json") diff --git a/pkg/cmd/attestation/verification/attestation_test.go b/pkg/cmd/attestation/verification/attestation_test.go index 166339bca..8acff0c37 100644 --- a/pkg/cmd/attestation/verification/attestation_test.go +++ b/pkg/cmd/attestation/verification/attestation_test.go @@ -14,7 +14,6 @@ import ( ) func TestLoadBundlesFromJSONLinesFile(t *testing.T) { - t.Skip() t.Run("with original file", func(t *testing.T) { path := "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl" attestations, err := loadBundlesFromJSONLinesFile(path) @@ -44,7 +43,6 @@ func TestLoadBundlesFromJSONLinesFile(t *testing.T) { } func TestLoadBundlesFromJSONLinesFile_RejectEmptyJSONLFile(t *testing.T) { - t.Skip() // Create a temporary file emptyJSONL, err := os.CreateTemp("", "empty.jsonl") require.NoError(t, err) @@ -58,7 +56,6 @@ func TestLoadBundlesFromJSONLinesFile_RejectEmptyJSONLFile(t *testing.T) { } func TestLoadBundleFromJSONFile(t *testing.T) { - t.Skip() path := "../test/data/sigstore-js-2.1.0-bundle.json" attestations, err := loadBundleFromJSONFile(path) @@ -67,7 +64,6 @@ func TestLoadBundleFromJSONFile(t *testing.T) { } func TestGetLocalAttestations(t *testing.T) { - t.Skip() t.Run("with JSON file containing one bundle", func(t *testing.T) { path := "../test/data/sigstore-js-2.1.0-bundle.json" attestations, err := GetLocalAttestations(path) @@ -122,7 +118,6 @@ func TestGetLocalAttestations(t *testing.T) { } func TestFilterAttestations(t *testing.T) { - t.Skip() attestations := []*api.Attestation{ { Bundle: &bundle.Bundle{ diff --git a/pkg/cmd/attestation/verification/extensions_test.go b/pkg/cmd/attestation/verification/extensions_test.go index 2e9ea1745..73d808119 100644 --- a/pkg/cmd/attestation/verification/extensions_test.go +++ b/pkg/cmd/attestation/verification/extensions_test.go @@ -25,7 +25,6 @@ func createSampleResult() *AttestationProcessingResult { } func TestVerifyCertExtensions(t *testing.T) { - t.Skip() results := []*AttestationProcessingResult{createSampleResult()} certSummary := certificate.Summary{} diff --git a/pkg/cmd/attestation/verification/tuf_test.go b/pkg/cmd/attestation/verification/tuf_test.go index dc2b36bb6..e8b6ecf98 100644 --- a/pkg/cmd/attestation/verification/tuf_test.go +++ b/pkg/cmd/attestation/verification/tuf_test.go @@ -11,7 +11,6 @@ import ( ) func TestGitHubTUFOptionsNoMetadataDir(t *testing.T) { - t.Skip() os.Setenv("CODESPACES", "true") opts := GitHubTUFOptions(o.None[string]()) diff --git a/pkg/cmd/attestation/verify/options_test.go b/pkg/cmd/attestation/verify/options_test.go index 3f5a79065..bdb851e7b 100644 --- a/pkg/cmd/attestation/verify/options_test.go +++ b/pkg/cmd/attestation/verify/options_test.go @@ -23,7 +23,6 @@ var baseOptions = Options{ } func TestAreFlagsValid(t *testing.T) { - t.Skip() t.Run("has invalid Repo value", func(t *testing.T) { opts := baseOptions opts.Repo = "sigstoresigstore-js" diff --git a/pkg/cmd/attestation/verify/policy_test.go b/pkg/cmd/attestation/verify/policy_test.go index 719119083..ff10cad11 100644 --- a/pkg/cmd/attestation/verify/policy_test.go +++ b/pkg/cmd/attestation/verify/policy_test.go @@ -9,7 +9,6 @@ import ( ) func TestNewEnforcementCriteria(t *testing.T) { - t.Skip() artifactPath := "../test/data/sigstore-js-2.1.0.tgz" t.Run("sets SANRegex and SAN using SANRegex and SAN", func(t *testing.T) { diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index e6bdd1cc8..92864f78e 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -17,7 +17,6 @@ import ( ) func TestVerifyIntegration(t *testing.T) { - t.Skip() logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ @@ -131,7 +130,6 @@ func TestVerifyIntegration(t *testing.T) { } func TestVerifyIntegrationCustomIssuer(t *testing.T) { - t.Skip() artifactPath := test.NormalizeRelativePath("../test/data/custom-issuer-artifact") bundlePath := test.NormalizeRelativePath("../test/data/custom-issuer.sigstore.json") @@ -205,7 +203,6 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { } func TestVerifyIntegrationReusableWorkflow(t *testing.T) { - t.Skip() artifactPath := test.NormalizeRelativePath("../test/data/reusable-workflow-artifact") bundlePath := test.NormalizeRelativePath("../test/data/reusable-workflow-attestation.sigstore.json") @@ -298,7 +295,6 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { } func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { - t.Skip() artifactPath := test.NormalizeRelativePath("../test/data/reusable-workflow-artifact") bundlePath := test.NormalizeRelativePath("../test/data/reusable-workflow-attestation.sigstore.json") diff --git a/pkg/cmd/attestation/verify/verify_test.go b/pkg/cmd/attestation/verify/verify_test.go index 40635c220..092a009d8 100644 --- a/pkg/cmd/attestation/verify/verify_test.go +++ b/pkg/cmd/attestation/verify/verify_test.go @@ -34,7 +34,6 @@ var ( ) func TestNewVerifyCmd(t *testing.T) { - t.Skip() testIO, _, _, _ := iostreams.Test() var testReg httpmock.Registry var metaResp = api.MetaResponse{ @@ -316,7 +315,6 @@ func TestNewVerifyCmd(t *testing.T) { } func TestVerifyCmdAuthChecks(t *testing.T) { - t.Skip() f := &cmdutil.Factory{} t.Run("by default auth check is required", func(t *testing.T) { @@ -347,7 +345,6 @@ func TestVerifyCmdAuthChecks(t *testing.T) { } func TestJSONOutput(t *testing.T) { - t.Skip() testIO, _, out, _ := iostreams.Test() opts := Options{ ArtifactPath: artifactPath, @@ -371,7 +368,6 @@ func TestJSONOutput(t *testing.T) { } func TestRunVerify(t *testing.T) { - t.Skip() logger := io.NewTestHandler() publicGoodOpts := Options{ From 3b2e7f7f712b473dc4378b56d907f98c80255931 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 9 Apr 2025 08:13:21 -0600 Subject: [PATCH 067/147] chore: go mod tidy --- go.mod | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 3a800505d..c44b83a20 100644 --- a/go.mod +++ b/go.mod @@ -126,7 +126,8 @@ require ( github.com/klauspost/compress v1.17.11 // indirect github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect From a34c9ea79937f4cb7b2d01ee4e60d495d86d7abe Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 9 Apr 2025 08:20:07 -0600 Subject: [PATCH 068/147] doc(prompter env): accessible prompter includes braille reader Co-authored-by: Andy Feller --- pkg/cmd/root/help_topic.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index dc39fd8d2..b85d64ca3 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -113,7 +113,7 @@ var HelpTopics = []helpTopic{ not specified. This value is used, for example, with %[1]spr view%[1]s subcommand. %[1]sGH_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are - more compatible with speech synthesis based screen readers. + more compatible with speech synthesis and braille screen readers. `, "`"), }, { From 8fc8486af5ff47730c0f5021d16a6009377849d6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 9 Apr 2025 08:24:54 -0600 Subject: [PATCH 069/147] refactor(prompter): rename speechSynthesizerFriendlyPrompter to accessiblePrompter --- ...er_test.go => accessible_prompter_test.go} | 10 ++++---- internal/prompter/prompter.go | 24 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) rename internal/prompter/{speech_synthesizer_friendly_prompter_test.go => accessible_prompter_test.go} (97%) diff --git a/internal/prompter/speech_synthesizer_friendly_prompter_test.go b/internal/prompter/accessible_prompter_test.go similarity index 97% rename from internal/prompter/speech_synthesizer_friendly_prompter_test.go rename to internal/prompter/accessible_prompter_test.go index 548825707..fcb806641 100644 --- a/internal/prompter/speech_synthesizer_friendly_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -19,7 +19,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestSpeechSynthesizerFriendlyPrompter(t *testing.T) { +func TestAccessiblePrompter(t *testing.T) { // Create a PTY and hook up a virtual terminal emulator ptm, pts, err := pty.Open() require.NoError(t, err) @@ -459,9 +459,9 @@ func TestSurveyPrompter(t *testing.T) { var wg sync.WaitGroup // This not a comprehensive test of the survey prompter, but it does - // demonstrate that the survey prompter is used when the speech - // synthesizer friendly prompter is disabled. - t.Run("Select uses survey prompter when speech synthesizer friendly prompter is disabled", func(t *testing.T) { + // demonstrate that the survey prompter is used when the + // accessible prompter is disabled. + t.Run("Select uses survey prompter when accessible prompter is disabled", func(t *testing.T) { wg.Add(1) go func() { @@ -471,7 +471,7 @@ func TestSurveyPrompter(t *testing.T) { require.NoError(t, err) // Send a newline to select the first option - // Note: This would not work with the speech synthesizer friendly prompter + // Note: This would not work with the accessible prompter // because it would requires sending a 1 to select the first option. // So it proves we are seeing a survey prompter. _, err = console.SendLine("") diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index bf5655846..f3a87c475 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -48,7 +48,7 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr falseyValues := []string{"false", "0", "no", ""} if accessiblePrompterIsSet && !slices.Contains(falseyValues, accessiblePrompterValue) { - return &speechSynthesizerFriendlyPrompter{ + return &accessiblePrompter{ stdin: stdin, stdout: stdout, stderr: stderr, @@ -65,14 +65,14 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr } } -type speechSynthesizerFriendlyPrompter struct { +type accessiblePrompter struct { stdin ghPrompter.FileReader stdout ghPrompter.FileWriter stderr ghPrompter.FileWriter editorCmd string } -func (p *speechSynthesizerFriendlyPrompter) newForm(groups ...*huh.Group) *huh.Form { +func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). WithTheme(huh.ThemeBase16()). WithAccessible(true) @@ -80,7 +80,7 @@ func (p *speechSynthesizerFriendlyPrompter) newForm(groups ...*huh.Group) *huh.F // WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) } -func (p *speechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []string) (int, error) { +func (p *accessiblePrompter) Select(prompt, _ string, options []string) (int, error) { var result int formOptions := []huh.Option[int]{} for i, o := range options { @@ -100,7 +100,7 @@ func (p *speechSynthesizerFriendlyPrompter) Select(prompt, _ string, options []s return result, err } -func (p *speechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { +func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { var result []int formOptions := make([]huh.Option[int], len(options)) for i, o := range options { @@ -125,7 +125,7 @@ func (p *speechSynthesizerFriendlyPrompter) MultiSelect(prompt string, defaults return result[:mid], nil } -func (p *speechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) (string, error) { +func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) { result := defaultValue prompt = fmt.Sprintf("%s (%s)", prompt, defaultValue) form := p.newForm( @@ -144,7 +144,7 @@ func (p *speechSynthesizerFriendlyPrompter) Input(prompt, defaultValue string) ( return result, err } -func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, error) { +func (p *accessiblePrompter) Password(prompt string) (string, error) { var result string // EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode. form := p.newForm( @@ -163,7 +163,7 @@ func (p *speechSynthesizerFriendlyPrompter) Password(prompt string) (string, err return result, nil } -func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { +func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) { // This is currently an ineffectual assignment because the value is // not respected as the default in accessible mode. Leaving this in here // because it may change in the future. @@ -182,7 +182,7 @@ func (p *speechSynthesizerFriendlyPrompter) Confirm(prompt string, defaultValue return result, nil } -func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { +func (p *accessiblePrompter) AuthToken() (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -205,7 +205,7 @@ func (p *speechSynthesizerFriendlyPrompter) AuthToken() (string, error) { return result, err } -func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string) error { +func (p *accessiblePrompter) ConfirmDeletion(requiredValue string) error { form := p.newForm( huh.NewGroup( huh.NewInput(). @@ -222,7 +222,7 @@ func (p *speechSynthesizerFriendlyPrompter) ConfirmDeletion(requiredValue string return form.Run() } -func (p *speechSynthesizerFriendlyPrompter) InputHostname() (string, error) { +func (p *accessiblePrompter) InputHostname() (string, error) { var result string form := p.newForm( huh.NewGroup( @@ -240,7 +240,7 @@ func (p *speechSynthesizerFriendlyPrompter) InputHostname() (string, error) { return result, nil } -func (p *speechSynthesizerFriendlyPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { +func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string skipOption := "skip" openOption := "open" From 2f5e8965355bd120364cccb52e6f973a6e85718c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:18:53 -0600 Subject: [PATCH 070/147] fix(prompter): update `huh` and fix tests --- go.mod | 6 +++--- go.sum | 6 ++++++ internal/prompter/accessible_prompter_test.go | 4 ---- internal/prompter/prompter.go | 7 +------ 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index c44b83a20..e0f76dce8 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/briandowns/spinner v1.18.1 github.com/cenkalti/backoff/v4 v4.3.0 github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 - github.com/charmbracelet/huh v0.6.0 + github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5 github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc github.com/cli/go-gh/v2 v2.12.0 github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 @@ -71,8 +71,8 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/blang/semver v3.5.1+incompatible // indirect - github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/bubbles v0.20.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect github.com/charmbracelet/bubbletea v1.3.4 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect diff --git a/go.sum b/go.sum index 0c23b57d1..bf7f39142 100644 --- a/go.sum +++ b/go.sum @@ -98,12 +98,16 @@ github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gL github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= @@ -112,6 +116,8 @@ github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 h1:hx6E25S github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3/go.mod h1:ihVqv4/YOY5Fweu1cxajuQrwJFh3zU4Ukb4mHVNjq3s= github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= +github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5 h1:uOnMxWghHfEYm2DPMeIHHAEirV/TduBVC9ZRXGcX9Q8= +github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5/go.mod h1:xl27E/xNaX3WwdkqpvBwjJcGWhupkU52CWLC5hReBTw= github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc h1:nFRtCfZu/zkltd2lsLUPlVNv3ej/Atod9hcdbRZtlys= github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index fcb806641..0b0f6844f 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -202,11 +202,7 @@ func TestAccessiblePrompter(t *testing.T) { wg.Wait() }) - // This test currently fails because the value is - // not respected as the default in accessible mode. - // See https://github.com/charmbracelet/huh/issues/615 t.Run("Confirm - blank input returns default", func(t *testing.T) { - t.Skip("Skipped due to https://github.com/charmbracelet/huh/issues/615") go func() { // Wait for prompt to appear _, err := console.ExpectString("Are you sure") diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index f3a87c475..d50150098 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -121,8 +121,7 @@ func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, optio return nil, err } - mid := len(result) / 2 - return result[:mid], nil + return result, nil } func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) { @@ -164,10 +163,6 @@ func (p *accessiblePrompter) Password(prompt string) (string, error) { } func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) { - // This is currently an ineffectual assignment because the value is - // not respected as the default in accessible mode. Leaving this in here - // because it may change in the future. - // See https://github.com/charmbracelet/huh/issues/615 result := defaultValue form := p.newForm( huh.NewGroup( From fab0de5583da0bc9373372a9049159162fb009a0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:37:48 -0600 Subject: [PATCH 071/147] fix(prompter): pass io to `huh` and refactor tests --- internal/prompter/accessible_prompter_test.go | 220 +++++++----------- internal/prompter/prompter.go | 6 +- 2 files changed, 84 insertions(+), 142 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 0b0f6844f..ed96cd65c 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -5,9 +5,7 @@ package prompter_test import ( "fmt" "io" - "os" "strings" - "sync" "testing" "time" @@ -20,54 +18,11 @@ import ( ) func TestAccessiblePrompter(t *testing.T) { - // Create a PTY and hook up a virtual terminal emulator - ptm, pts, err := pty.Open() - require.NoError(t, err) - - term := vt10x.New(vt10x.WithWriter(pts)) - - // Create a console via Expect that allows scripting against the terminal - consoleOpts := []expect.ConsoleOpt{ - expect.WithStdin(ptm), - expect.WithStdout(term), - expect.WithCloser(ptm, pts), - failOnExpectError(t), - failOnSendError(t), - expect.WithDefaultTimeout(time.Second), - } - - console, err := expect.NewConsole(consoleOpts...) - require.NoError(t, err) - t.Cleanup(func() { testCloser(t, console) }) - - // Using OS here because huh currently ignores configured iostreams - // See https://github.com/charmbracelet/huh/issues/612 - stdIn := os.Stdin - stdOut := os.Stdout - stdErr := os.Stderr - - t.Cleanup(func() { - os.Stdin = stdIn - os.Stdout = stdOut - os.Stderr = stdErr - }) - - os.Stdin = console.Tty() - os.Stdout = console.Tty() - os.Stderr = console.Tty() - - t.Setenv("GH_ACCESSIBLE_PROMPTER", "true") - // Using echo as the editor command here because it will immediately exit - // and return no input. - p := prompter.New("echo", nil, nil, nil) - - var wg sync.WaitGroup - t.Run("Select", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Choose:") require.NoError(t, err) @@ -80,15 +35,13 @@ func TestAccessiblePrompter(t *testing.T) { selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) require.NoError(t, err) assert.Equal(t, 0, selectValue) - - wg.Wait() }) t.Run("MultiSelect", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Select a number") require.NoError(t, err) @@ -107,16 +60,14 @@ func TestAccessiblePrompter(t *testing.T) { multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"}) require.NoError(t, err) assert.Equal(t, []int{0, 1}, multiSelectValue) - - wg.Wait() }) t.Run("Input", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) dummyText := "12345abcdefg" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Enter some characters") require.NoError(t, err) @@ -129,16 +80,14 @@ func TestAccessiblePrompter(t *testing.T) { inputValue, err := p.Input("Enter some characters", "") require.NoError(t, err) assert.Equal(t, dummyText, inputValue) - - wg.Wait() }) t.Run("Input - blank input returns default value", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) dummyDefaultValue := "12345abcdefg" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Enter some characters") require.NoError(t, err) @@ -155,16 +104,14 @@ func TestAccessiblePrompter(t *testing.T) { inputValue, err := p.Input("Enter some characters", dummyDefaultValue) require.NoError(t, err) assert.Equal(t, dummyDefaultValue, inputValue) - - wg.Wait() }) t.Run("Password", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) dummyPassword := "12345abcdefg" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Enter password") require.NoError(t, err) @@ -177,15 +124,13 @@ func TestAccessiblePrompter(t *testing.T) { passwordValue, err := p.Password("Enter password") require.NoError(t, err) require.Equal(t, dummyPassword, passwordValue) - - wg.Wait() }) t.Run("Confirm", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Are you sure") require.NoError(t, err) @@ -198,11 +143,12 @@ func TestAccessiblePrompter(t *testing.T) { confirmValue, err := p.Confirm("Are you sure", false) require.NoError(t, err) require.Equal(t, true, confirmValue) - - wg.Wait() }) t.Run("Confirm - blank input returns default", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) + go func() { // Wait for prompt to appear _, err := console.ExpectString("Are you sure") @@ -219,11 +165,11 @@ func TestAccessiblePrompter(t *testing.T) { }) t.Run("AuthToken", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) dummyAuthToken := "12345abcdefg" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Paste your authentication token:") require.NoError(t, err) @@ -236,16 +182,14 @@ func TestAccessiblePrompter(t *testing.T) { authValue, err := p.AuthToken() require.NoError(t, err) require.Equal(t, dummyAuthToken, authValue) - - wg.Wait() }) t.Run("AuthToken - blank input returns error", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) dummyAuthTokenForAfterFailure := "12345abcdefg" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Paste your authentication token:") require.NoError(t, err) @@ -266,16 +210,14 @@ func TestAccessiblePrompter(t *testing.T) { authValue, err := p.AuthToken() require.NoError(t, err) require.Equal(t, dummyAuthTokenForAfterFailure, authValue) - - wg.Wait() }) t.Run("ConfirmDeletion", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) requiredValue := "test" go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) require.NoError(t, err) @@ -288,17 +230,15 @@ func TestAccessiblePrompter(t *testing.T) { // An err indicates that the confirmation text sent did not match err := p.ConfirmDeletion(requiredValue) require.NoError(t, err) - - wg.Wait() }) t.Run("ConfirmDeletion - bad input", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) requiredValue := "test" badInputValue := "garbage" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue)) require.NoError(t, err) @@ -319,16 +259,14 @@ func TestAccessiblePrompter(t *testing.T) { // An err indicates that the confirmation text sent did not match err := p.ConfirmDeletion(requiredValue) require.NoError(t, err) - - wg.Wait() }) t.Run("InputHostname", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) hostname := "example.com" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Hostname:") require.NoError(t, err) @@ -341,15 +279,13 @@ func TestAccessiblePrompter(t *testing.T) { inputValue, err := p.InputHostname() require.NoError(t, err) require.Equal(t, hostname, inputValue) - - wg.Wait() }) t.Run("MarkdownEditor - blank allowed with blank input returns blank", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("How to edit?") require.NoError(t, err) @@ -362,16 +298,14 @@ func TestAccessiblePrompter(t *testing.T) { inputValue, err := p.MarkdownEditor("How to edit?", "", true) require.NoError(t, err) require.Equal(t, "", inputValue) - - wg.Wait() }) t.Run("MarkdownEditor - blank disallowed with default value returns default value", func(t *testing.T) { - wg.Add(1) - + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) defaultValue := "12345abcdefg" + go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("How to edit?") require.NoError(t, err) @@ -392,15 +326,13 @@ func TestAccessiblePrompter(t *testing.T) { inputValue, err := p.MarkdownEditor("How to edit?", defaultValue, false) require.NoError(t, err) require.Equal(t, defaultValue, inputValue) - - wg.Wait() }) t.Run("MarkdownEditor - blank disallowed no default value returns error", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestAcessiblePrompter(t, console) go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("How to edit?") require.NoError(t, err) @@ -422,46 +354,18 @@ func TestAccessiblePrompter(t *testing.T) { inputValue, err := p.MarkdownEditor("How to edit?", "", false) require.NoError(t, err) require.Equal(t, "", inputValue) - - wg.Wait() }) } func TestSurveyPrompter(t *testing.T) { - // Create a PTY and hook up a virtual terminal emulator - ptm, pts, err := pty.Open() - require.NoError(t, err) - - term := vt10x.New(vt10x.WithWriter(pts)) - - // Create a console via Expect that allows scripting against the terminal - consoleOpts := []expect.ConsoleOpt{ - expect.WithStdin(ptm), - expect.WithStdout(term), - expect.WithCloser(ptm, pts), - failOnExpectError(t), - failOnSendError(t), - expect.WithDefaultTimeout(time.Second * 600), - } - - console, err := expect.NewConsole(consoleOpts...) - require.NoError(t, err) - t.Cleanup(func() { testCloser(t, console) }) - - // Using echo as the editor command here because it will immediately exit - // and return no input. - p := prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) - - var wg sync.WaitGroup - // This not a comprehensive test of the survey prompter, but it does // demonstrate that the survey prompter is used when the // accessible prompter is disabled. t.Run("Select uses survey prompter when accessible prompter is disabled", func(t *testing.T) { - wg.Add(1) + console := newTestVirtualTerminal(t) + p := newTestSurveyPrompter(t, console) go func() { - defer wg.Done() // Wait for prompt to appear _, err := console.ExpectString("Select a number") require.NoError(t, err) @@ -477,11 +381,49 @@ func TestSurveyPrompter(t *testing.T) { selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"}) require.NoError(t, err) assert.Equal(t, 0, selectValue) - - wg.Wait() }) } +func newTestVirtualTerminal(t testing.TB) *expect.Console { + t.Helper() + + // Create a PTY and hook up a virtual terminal emulator + ptm, pts, err := pty.Open() + require.NoError(t, err) + + term := vt10x.New(vt10x.WithWriter(pts)) + + // Create a console via Expect that allows scripting against the terminal + consoleOpts := []expect.ConsoleOpt{ + expect.WithStdin(ptm), + expect.WithStdout(term), + expect.WithCloser(ptm, pts), + failOnExpectError(t), + failOnSendError(t), + expect.WithDefaultTimeout(time.Second), + } + + console, err := expect.NewConsole(consoleOpts...) + require.NoError(t, err) + t.Cleanup(func() { testCloser(t, console) }) + + return console +} + +func newTestAcessiblePrompter(t testing.TB, console *expect.Console) prompter.Prompter { + t.Helper() + + t.Setenv("GH_ACCESSIBLE_PROMPTER", "true") + return prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) +} + +func newTestSurveyPrompter(t testing.TB, console *expect.Console) prompter.Prompter { + t.Helper() + + t.Setenv("GH_ACCESSIBLE_PROMPTER", "false") + return prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) +} + // failOnExpectError adds an observer that will fail the test in a standardised way // if any expectation on the command output fails, without requiring an explicit // assertion. diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index d50150098..6cdbc9a87 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -75,9 +75,9 @@ type accessiblePrompter struct { func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form { return huh.NewForm(groups...). WithTheme(huh.ThemeBase16()). - WithAccessible(true) - // Commented out because https://github.com/charmbracelet/huh/issues/612 - // WithProgramOptions(tea.WithOutput(p.stdout), tea.WithInput(p.stdin)) + WithAccessible(true). + WithInput(p.stdin). + WithOutput(p.stdout) } func (p *accessiblePrompter) Select(prompt, _ string, options []string) (int, error) { From 46150697735ba6965e851a68456533b2af8e5035 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:43:18 -0600 Subject: [PATCH 072/147] chore: go mod tidy --- go.sum | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/go.sum b/go.sum index bf7f39142..5441c96c5 100644 --- a/go.sum +++ b/go.sum @@ -96,16 +96,12 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY= github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= -github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= -github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= -github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= @@ -114,8 +110,6 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 h1:hx6E25SvI2WiZdt/gxINcYBnHD7PE2Vr9auqwg5B05g= github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3/go.mod h1:ihVqv4/YOY5Fweu1cxajuQrwJFh3zU4Ukb4mHVNjq3s= -github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= -github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5 h1:uOnMxWghHfEYm2DPMeIHHAEirV/TduBVC9ZRXGcX9Q8= github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5/go.mod h1:xl27E/xNaX3WwdkqpvBwjJcGWhupkU52CWLC5hReBTw= github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc h1:nFRtCfZu/zkltd2lsLUPlVNv3ej/Atod9hcdbRZtlys= @@ -124,8 +118,8 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= From 072534c388cf20924be3e359819c2674d6d4190a Mon Sep 17 00:00:00 2001 From: leudz Date: Thu, 10 Apr 2025 07:57:43 +0200 Subject: [PATCH 073/147] Fix multi pages search for gh search --- pkg/search/searcher.go | 88 ++++++++--- pkg/search/searcher_test.go | 291 ++++++++++++++++++++++++++++++++++-- 2 files changed, 343 insertions(+), 36 deletions(-) diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 4168dc7f3..155484ac9 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -14,6 +14,7 @@ import ( ) const ( + // GitHub API has a limit of 100 per page maxPerPage = 100 orderKey = "order" sortKey = "sort" @@ -60,96 +61,138 @@ func NewSearcher(client *http.Client, host string) Searcher { func (s searcher) Code(query Query) (CodeResult, error) { result := CodeResult{} - toRetrieve := query.Limit + var resp *http.Response var err error + + toRetrieve := query.Limit + // We will request either the query limit if it's less than 1 page, or our max page size. + // This number doesn't change to keep a valid offset. + // + // For example, say we want 150 items out of 500. + // We request page #1 for 100 items and get items 0 to 99. + // Then we request page #2 for 100 items, we get items 100 to 199 and only keep 100 to 149. + // If we were to request page #2 for 50 items, we would instead get items 50 to 99. + query.Limit = min(toRetrieve, maxPerPage) + for toRetrieve > 0 { - query.Limit = min(toRetrieve, maxPerPage) query.Page = nextPage(resp) if query.Page == 0 { break } + page := CodeResult{} resp, err = s.search(query, &page) if err != nil { return result, err } + result.IncompleteResults = page.IncompleteResults - result.Total = page.Total - result.Items = append(result.Items, page.Items...) - toRetrieve = toRetrieve - len(page.Items) + + // If we're going to reach the requested limit, only add that many items, + // otherwise add all the results. + itemsToAdd := min(len(page.Items), toRetrieve) + + result.Total += itemsToAdd + result.Items = append(result.Items, page.Items[:itemsToAdd]...) + toRetrieve = toRetrieve - itemsToAdd } + return result, nil } func (s searcher) Commits(query Query) (CommitsResult, error) { result := CommitsResult{} - toRetrieve := query.Limit + var resp *http.Response var err error + + toRetrieve := query.Limit + query.Limit = min(toRetrieve, maxPerPage) + for toRetrieve > 0 { - query.Limit = min(toRetrieve, maxPerPage) query.Page = nextPage(resp) if query.Page == 0 { break } + page := CommitsResult{} resp, err = s.search(query, &page) if err != nil { return result, err } + result.IncompleteResults = page.IncompleteResults - result.Total = page.Total - result.Items = append(result.Items, page.Items...) - toRetrieve = toRetrieve - len(page.Items) + + itemsToAdd := min(len(page.Items), toRetrieve) + + result.Total += itemsToAdd + result.Items = append(result.Items, page.Items[:itemsToAdd]...) + toRetrieve = toRetrieve - itemsToAdd } return result, nil } func (s searcher) Repositories(query Query) (RepositoriesResult, error) { result := RepositoriesResult{} - toRetrieve := query.Limit + var resp *http.Response var err error + + toRetrieve := query.Limit + query.Limit = min(toRetrieve, maxPerPage) + for toRetrieve > 0 { - query.Limit = min(toRetrieve, maxPerPage) query.Page = nextPage(resp) if query.Page == 0 { break } + page := RepositoriesResult{} resp, err = s.search(query, &page) if err != nil { return result, err } + result.IncompleteResults = page.IncompleteResults - result.Total = page.Total - result.Items = append(result.Items, page.Items...) - toRetrieve = toRetrieve - len(page.Items) + + itemsToAdd := min(len(page.Items), toRetrieve) + + result.Total += itemsToAdd + result.Items = append(result.Items, page.Items[:itemsToAdd]...) + toRetrieve = toRetrieve - itemsToAdd } return result, nil } func (s searcher) Issues(query Query) (IssuesResult, error) { result := IssuesResult{} - toRetrieve := query.Limit + var resp *http.Response var err error + + toRetrieve := query.Limit + query.Limit = min(toRetrieve, maxPerPage) + for toRetrieve > 0 { - query.Limit = min(toRetrieve, maxPerPage) query.Page = nextPage(resp) if query.Page == 0 { break } + page := IssuesResult{} resp, err = s.search(query, &page) if err != nil { return result, err } + result.IncompleteResults = page.IncompleteResults - result.Total = page.Total - result.Items = append(result.Items, page.Items...) - toRetrieve = toRetrieve - len(page.Items) + + itemsToAdd := min(len(page.Items), toRetrieve) + + result.Total += itemsToAdd + result.Items = append(result.Items, page.Items[:itemsToAdd]...) + toRetrieve = toRetrieve - itemsToAdd } return result, nil } @@ -236,10 +279,15 @@ func handleHTTPError(resp *http.Response) error { return httpError } +// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api func nextPage(resp *http.Response) (page int) { if resp == nil { return 1 } + + // When using pagination, responses get a "Link" field in their header. + // When a next page is available, "Link" contains a link to the next page + // tagged with rel="next". for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) { if !(len(m) > 2 && m[2] == "next") { continue diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go index 8642feed0..1ab8379dd 100644 --- a/pkg/search/searcher_test.go +++ b/pkg/search/searcher_test.go @@ -3,6 +3,7 @@ package search import ( "net/http" "net/url" + "strconv" "testing" "github.com/MakeNowJust/heredoc" @@ -26,6 +27,21 @@ func TestSearcherCode(t *testing.T) { "q": []string{"keyword language:go"}, } + multiplePagesTotalItems := make([]Code, 0, 110) + multiplePagesFirstResItems := make([]Code, 0, 100) + multiplePagesSecondResItems := make([]Code, 0, 10) + for i := range 110 { + commit := Code{Name: "name" + strconv.Itoa(i) + ".go"} + + multiplePagesTotalItems = append(multiplePagesTotalItems, commit) + + if i < 100 { + multiplePagesFirstResItems = append(multiplePagesFirstResItems, commit) + } else { + multiplePagesSecondResItems = append(multiplePagesSecondResItems, commit) + } + } + tests := []struct { name string host string @@ -87,20 +103,64 @@ func TestSearcherCode(t *testing.T) { firstRes := httpmock.JSONResponse(CodeResult{ IncompleteResults: false, Items: []Code{{Name: "file.go"}}, - Total: 2, + Total: 1, }, ) - firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/code", url.Values{ "page": []string{"2"}, - "per_page": []string{"29"}, + "per_page": []string{"30"}, "q": []string{"keyword language:go"}, }, ) secondRes := httpmock.JSONResponse(CodeResult{ IncompleteResults: false, Items: []Code{{Name: "file2.go"}}, - Total: 2, + Total: 1, + }, + ) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, + { + name: "collects results for limit above one page", + query: Query{ + Keywords: []string{"keyword"}, + Kind: "code", + Limit: 110, + Qualifiers: Qualifiers{ + Language: "go", + }, + }, + result: CodeResult{ + IncompleteResults: false, + Items: multiplePagesTotalItems, + Total: 110, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/code", url.Values{ + "page": []string{"1"}, + "per_page": []string{"100"}, + "q": []string{"keyword language:go"}, + }) + firstRes := httpmock.JSONResponse(CodeResult{ + IncompleteResults: false, + Items: multiplePagesFirstResItems, + Total: 100, + }, + ) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/code", url.Values{ + "page": []string{"2"}, + "per_page": []string{"100"}, + "q": []string{"keyword language:go"}, + }, + ) + secondRes := httpmock.JSONResponse(CodeResult{ + IncompleteResults: false, + Items: multiplePagesSecondResItems, + Total: 10, }, ) reg.Register(firstReq, firstRes) @@ -181,6 +241,21 @@ func TestSearcherCommits(t *testing.T) { "q": []string{"keyword author:foobar committer-date:>2021-02-28"}, } + multiplePagesTotalItems := make([]Commit, 0, 110) + multiplePagesFirstResItems := make([]Commit, 0, 100) + multiplePagesSecondResItems := make([]Commit, 0, 10) + for i := range 110 { + commit := Commit{Sha: strconv.Itoa(i)} + + multiplePagesTotalItems = append(multiplePagesTotalItems, commit) + + if i < 100 { + multiplePagesFirstResItems = append(multiplePagesFirstResItems, commit) + } else { + multiplePagesSecondResItems = append(multiplePagesSecondResItems, commit) + } + } + tests := []struct { name string host string @@ -242,13 +317,13 @@ func TestSearcherCommits(t *testing.T) { firstRes := httpmock.JSONResponse(CommitsResult{ IncompleteResults: false, Items: []Commit{{Sha: "abc"}}, - Total: 2, + Total: 1, }, ) - firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ "page": []string{"2"}, - "per_page": []string{"29"}, + "per_page": []string{"30"}, "order": []string{"desc"}, "sort": []string{"committer-date"}, "q": []string{"keyword author:foobar committer-date:>2021-02-28"}, @@ -257,7 +332,58 @@ func TestSearcherCommits(t *testing.T) { secondRes := httpmock.JSONResponse(CommitsResult{ IncompleteResults: false, Items: []Commit{{Sha: "def"}}, - Total: 2, + Total: 1, + }, + ) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, + { + name: "collects results for limit above one page", + query: Query{ + Keywords: []string{"keyword"}, + Kind: "commits", + Limit: 110, + Order: "desc", + Sort: "committer-date", + Qualifiers: Qualifiers{ + Author: "foobar", + CommitterDate: ">2021-02-28", + }, + }, + result: CommitsResult{ + IncompleteResults: false, + Items: multiplePagesTotalItems, + Total: 110, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ + "page": []string{"1"}, + "per_page": []string{"100"}, + "order": []string{"desc"}, + "sort": []string{"committer-date"}, + "q": []string{"keyword author:foobar committer-date:>2021-02-28"}, + }) + firstRes := httpmock.JSONResponse(CommitsResult{ + IncompleteResults: false, + Items: multiplePagesFirstResItems, + Total: 100, + }, + ) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ + "page": []string{"2"}, + "per_page": []string{"100"}, + "order": []string{"desc"}, + "sort": []string{"committer-date"}, + "q": []string{"keyword author:foobar committer-date:>2021-02-28"}, + }, + ) + secondRes := httpmock.JSONResponse(CommitsResult{ + IncompleteResults: false, + Items: multiplePagesSecondResItems, + Total: 10, }, ) reg.Register(firstReq, firstRes) @@ -338,6 +464,25 @@ func TestSearcherRepositories(t *testing.T) { "q": []string{"keyword stars:>=5 topic:topic"}, } + multiplePagesTotalItems := make([]Repository, 0, 110) + multiplePagesFirstResItems := make([]any, 0, 100) + multiplePagesSecondResItems := make([]any, 0, 10) + for i := range 110 { + num := strconv.Itoa(i) + + multiplePagesTotalItems = append(multiplePagesTotalItems, Repository{Name: "name" + num}) + + if i < 100 { + multiplePagesFirstResItems = append(multiplePagesFirstResItems, map[string]any{ + "name": "name" + num, + }) + } else { + multiplePagesSecondResItems = append(multiplePagesSecondResItems, map[string]any{ + "name": "name" + num, + }) + } + } + tests := []struct { name string host string @@ -406,17 +551,17 @@ func TestSearcherRepositories(t *testing.T) { firstReq := httpmock.QueryMatcher("GET", "search/repositories", values) firstRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 2, + "total_count": 1, "items": []interface{}{ map[string]interface{}{ "name": "test", }, }, }) - firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{ "page": []string{"2"}, - "per_page": []string{"29"}, + "per_page": []string{"30"}, "order": []string{"desc"}, "sort": []string{"stars"}, "q": []string{"keyword stars:>=5 topic:topic"}, @@ -424,7 +569,7 @@ func TestSearcherRepositories(t *testing.T) { ) secondRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 2, + "total_count": 1, "items": []interface{}{ map[string]interface{}{ "name": "cli", @@ -435,6 +580,54 @@ func TestSearcherRepositories(t *testing.T) { reg.Register(secondReq, secondRes) }, }, + { + name: "collects results for limit above one page", + query: Query{ + Keywords: []string{"keyword"}, + Kind: "repositories", + Limit: 110, + Order: "desc", + Sort: "stars", + Qualifiers: Qualifiers{ + Stars: ">=5", + Topic: []string{"topic"}, + }, + }, + result: RepositoriesResult{ + IncompleteResults: false, + Items: multiplePagesTotalItems, + Total: 110, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{ + "page": []string{"1"}, + "per_page": []string{"100"}, + "order": []string{"desc"}, + "sort": []string{"stars"}, + "q": []string{"keyword stars:>=5 topic:topic"}, + }) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 100, + "items": multiplePagesFirstResItems, + }) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{ + "page": []string{"2"}, + "per_page": []string{"100"}, + "order": []string{"desc"}, + "sort": []string{"stars"}, + "q": []string{"keyword stars:>=5 topic:topic"}, + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 10, + "items": multiplePagesSecondResItems, + }) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, { name: "handles search errors", query: query, @@ -509,6 +702,21 @@ func TestSearcherIssues(t *testing.T) { "q": []string{"keyword is:locked is:public language:go"}, } + multiplePagesTotalItems := make([]Issue, 0, 110) + multiplePagesFirstResItems := make([]Issue, 0, 100) + multiplePagesSecondResItems := make([]Issue, 0, 10) + for i := range 110 { + issue := Issue{Number: i} + + multiplePagesTotalItems = append(multiplePagesTotalItems, issue) + + if i < 100 { + multiplePagesFirstResItems = append(multiplePagesFirstResItems, issue) + } else { + multiplePagesSecondResItems = append(multiplePagesSecondResItems, issue) + } + } + tests := []struct { name string host string @@ -570,13 +778,13 @@ func TestSearcherIssues(t *testing.T) { firstRes := httpmock.JSONResponse(IssuesResult{ IncompleteResults: false, Items: []Issue{{Number: 1234}}, - Total: 2, + Total: 1, }, ) - firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{ "page": []string{"2"}, - "per_page": []string{"29"}, + "per_page": []string{"30"}, "order": []string{"desc"}, "sort": []string{"comments"}, "q": []string{"keyword is:locked is:public language:go"}, @@ -585,7 +793,58 @@ func TestSearcherIssues(t *testing.T) { secondRes := httpmock.JSONResponse(IssuesResult{ IncompleteResults: false, Items: []Issue{{Number: 5678}}, - Total: 2, + Total: 1, + }, + ) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, + { + name: "collects results for limit above one page", + query: Query{ + Keywords: []string{"keyword"}, + Kind: "issues", + Limit: 110, + Order: "desc", + Sort: "comments", + Qualifiers: Qualifiers{ + Language: "go", + Is: []string{"public", "locked"}, + }, + }, + result: IssuesResult{ + IncompleteResults: false, + Items: multiplePagesTotalItems, + Total: 110, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{ + "page": []string{"1"}, + "per_page": []string{"100"}, + "order": []string{"desc"}, + "sort": []string{"comments"}, + "q": []string{"keyword is:locked is:public language:go"}, + }) + firstRes := httpmock.JSONResponse(IssuesResult{ + IncompleteResults: false, + Items: multiplePagesFirstResItems, + Total: 100, + }, + ) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{ + "page": []string{"2"}, + "per_page": []string{"100"}, + "order": []string{"desc"}, + "sort": []string{"comments"}, + "q": []string{"keyword is:locked is:public language:go"}, + }, + ) + secondRes := httpmock.JSONResponse(IssuesResult{ + IncompleteResults: false, + Items: multiplePagesSecondResItems, + Total: 10, }, ) reg.Register(firstReq, firstRes) From 512fac2dba4a302561bef26b3aa8808ed0d5a927 Mon Sep 17 00:00:00 2001 From: Fredrik Skogman Date: Thu, 10 Apr 2025 08:39:12 +0200 Subject: [PATCH 074/147] Simplify cosign verification example by not using a regex. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ad012588c..cefe1abb0 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ There are two common ways to verify a downloaded release, depending if `gh` is a $ cosign verify-blob-attestation --bundle cli-cli-attestation-3120304.sigstore.json \ --new-bundle-format \ --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ - --certificate-identity-regexp='^https://github\.com/cli/cli/\.github/workflows/deployment\.yml@refs/heads/trunk$' \ + --certificate-identity="https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk" \ gh_2.62.0_macOS_arm64.zip Verified OK ``` From f0d4acd501ef19a3e24f0e9436c83460683bb0b1 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 10 Apr 2025 14:00:24 +0100 Subject: [PATCH 075/147] Add tests for `IsPinned` method Signed-off-by: Babak K. Shandiz --- pkg/cmd/extension/extension_test.go | 79 +++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/pkg/cmd/extension/extension_test.go b/pkg/cmd/extension/extension_test.go index 6f0afc3a6..a0635c9a2 100644 --- a/pkg/cmd/extension/extension_test.go +++ b/pkg/cmd/extension/extension_test.go @@ -102,3 +102,82 @@ func TestOwnerCached(t *testing.T) { assert.Equal(t, "cli", e.Owner()) } + +func TestIsPinnedBinaryExtensionUnpinned(t *testing.T) { + tempDir := t.TempDir() + extName := "gh-bin-ext" + extDir := filepath.Join(tempDir, "extensions", extName) + extPath := filepath.Join(extDir, extName) + bm := binManifest{ + Name: "gh-bin-ext", + } + assert.NoError(t, stubBinaryExtension(extDir, bm)) + e := &Extension{ + kind: BinaryKind, + path: extPath, + } + + assert.False(t, e.IsPinned()) +} + +func TestIsPinnedBinaryExtensionPinned(t *testing.T) { + tempDir := t.TempDir() + extName := "gh-bin-ext" + extDir := filepath.Join(tempDir, "extensions", extName) + extPath := filepath.Join(extDir, extName) + bm := binManifest{ + Name: "gh-bin-ext", + IsPinned: true, + } + assert.NoError(t, stubBinaryExtension(extDir, bm)) + e := &Extension{ + kind: BinaryKind, + path: extPath, + } + + assert.True(t, e.IsPinned()) +} + +func TestIsPinnedGitExtensionUnpinned(t *testing.T) { + tempDir := t.TempDir() + extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local") + assert.NoError(t, stubExtension(extPath)) + + gc := &mockGitClient{} + gc.On("CommandOutput", []string{"rev-parse", "HEAD"}).Return("abcd1234", nil) + e := &Extension{ + kind: GitKind, + gitClient: gc, + path: extPath, + } + + assert.False(t, e.IsPinned()) +} + +func TestIsPinnedGitExtensionPinned(t *testing.T) { + tempDir := t.TempDir() + extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local") + assert.NoError(t, stubPinnedExtension(extPath, "abcd1234")) + + gc := &mockGitClient{} + gc.On("CommandOutput", []string{"rev-parse", "HEAD"}).Return("abcd1234", nil) + e := &Extension{ + kind: GitKind, + gitClient: gc, + path: extPath, + } + + assert.True(t, e.IsPinned()) +} + +func TestIsPinnedLocalExtension(t *testing.T) { + tempDir := t.TempDir() + extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local") + assert.NoError(t, stubLocalExtension(tempDir, extPath)) + e := &Extension{ + kind: LocalKind, + path: extPath, + } + + assert.False(t, e.IsPinned()) +} From 791e1af8286a2e97b50ce2da800bf8168a07ec7d Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 10 Apr 2025 14:09:49 +0100 Subject: [PATCH 076/147] Add missing `gc.AssertExpectations` calls Signed-off-by: Babak K. Shandiz --- pkg/cmd/extension/extension_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cmd/extension/extension_test.go b/pkg/cmd/extension/extension_test.go index a0635c9a2..6928c6ed9 100644 --- a/pkg/cmd/extension/extension_test.go +++ b/pkg/cmd/extension/extension_test.go @@ -152,6 +152,7 @@ func TestIsPinnedGitExtensionUnpinned(t *testing.T) { } assert.False(t, e.IsPinned()) + gc.AssertExpectations(t) } func TestIsPinnedGitExtensionPinned(t *testing.T) { @@ -168,6 +169,7 @@ func TestIsPinnedGitExtensionPinned(t *testing.T) { } assert.True(t, e.IsPinned()) + gc.AssertExpectations(t) } func TestIsPinnedLocalExtension(t *testing.T) { From 47d603221d4cafebfc79619eb2296b8ea2328037 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:37:36 -0600 Subject: [PATCH 077/147] test(prompter): use *testing.T instead --- internal/prompter/accessible_prompter_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index ed96cd65c..e80c3085e 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -384,7 +384,7 @@ func TestSurveyPrompter(t *testing.T) { }) } -func newTestVirtualTerminal(t testing.TB) *expect.Console { +func newTestVirtualTerminal(t *testing.T) *expect.Console { t.Helper() // Create a PTY and hook up a virtual terminal emulator @@ -410,14 +410,14 @@ func newTestVirtualTerminal(t testing.TB) *expect.Console { return console } -func newTestAcessiblePrompter(t testing.TB, console *expect.Console) prompter.Prompter { +func newTestAcessiblePrompter(t *testing.T, console *expect.Console) prompter.Prompter { t.Helper() t.Setenv("GH_ACCESSIBLE_PROMPTER", "true") return prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) } -func newTestSurveyPrompter(t testing.TB, console *expect.Console) prompter.Prompter { +func newTestSurveyPrompter(t *testing.T, console *expect.Console) prompter.Prompter { t.Helper() t.Setenv("GH_ACCESSIBLE_PROMPTER", "false") @@ -429,7 +429,7 @@ func newTestSurveyPrompter(t testing.TB, console *expect.Console) prompter.Promp // assertion. // // Use WithRelaxedIO to disable this behaviour. -func failOnExpectError(t testing.TB) expect.ConsoleOpt { +func failOnExpectError(t *testing.T) expect.ConsoleOpt { t.Helper() return expect.WithExpectObserver( func(matchers []expect.Matcher, buf string, err error) { @@ -456,7 +456,7 @@ func failOnExpectError(t testing.TB) expect.ConsoleOpt { // if any sending of input fails, without requiring an explicit assertion. // // Use WithRelaxedIO to disable this behaviour. -func failOnSendError(t testing.TB) expect.ConsoleOpt { +func failOnSendError(t *testing.T) expect.ConsoleOpt { t.Helper() return expect.WithSendObserver( func(msg string, n int, err error) { @@ -473,7 +473,7 @@ func failOnSendError(t testing.TB) expect.ConsoleOpt { } // testCloser is a helper to fail the test if a Closer fails to close. -func testCloser(t testing.TB, closer io.Closer) { +func testCloser(t *testing.T, closer io.Closer) { t.Helper() if err := closer.Close(); err != nil { t.Errorf("Close failed: %s", err) From 8b70870f4f553ad364795e7523c1af00714b8ccf Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:39:59 -0600 Subject: [PATCH 078/147] test(prompter): describe why echo is editorcmd --- internal/prompter/accessible_prompter_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index e80c3085e..6d2bee92c 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -414,6 +414,9 @@ func newTestAcessiblePrompter(t *testing.T, console *expect.Console) prompter.Pr t.Helper() t.Setenv("GH_ACCESSIBLE_PROMPTER", "true") + // `echo`` is chose as the editor command because it immediately returns + // a success exit code, returns an empty string, doesn't require any user input, + // and since this file is only built on Linux, it is near guaranteed to be available. return prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) } From 9eee77a2bf290558d65c6111b5c9ed53bd5648fe Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:55:24 -0600 Subject: [PATCH 079/147] test(prompter): doc how accessible prompter tests work --- internal/prompter/accessible_prompter_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 6d2bee92c..60ae5162b 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -17,6 +17,19 @@ import ( "github.com/stretchr/testify/require" ) +// The following tests are broadly testing the accessible prompter, and NOT asserting +// on the prompter's complete and exact output strings. +// +// These tests generally operate with this logic: +// - Wait for a particular substring (a portion of the prompt) to appear +// - Send input +// - Wait for another substring to appear or for control to return to the test +// - Assert that the input value was returned from the prompter function + +// In the future, expanding these tests to assert on the exact prompt strings +// would help build confidence in `huh` upgrades, but for now these tests +// are sufficient to ensure that the accessible prompter behaves roughly as expected +// but doesn't mandate that prompts always look exactly the same. func TestAccessiblePrompter(t *testing.T) { t.Run("Select", func(t *testing.T) { console := newTestVirtualTerminal(t) From 20ff409bfcf0d7316fd05ab3d06a970a434cdbd0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:56:12 -0600 Subject: [PATCH 080/147] fix(prompter): remove needless default value assignment --- internal/prompter/prompter.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 6cdbc9a87..7dd45bf5c 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -136,10 +136,6 @@ func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) ) err := form.Run() - - if result == "" { - return defaultValue, nil - } return result, err } From b8cd094ca8f7a3cf3ab77a1e69f94eaae6403626 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Thu, 10 Apr 2025 18:11:38 -0400 Subject: [PATCH 081/147] Ensure markdown confirm prompt shows editor name Apparently, `gh` might not actually have an editor at the time we're prompting the user if they want to use it for markdown editing. In the survey package, there is a function that will handle fallback to the default editor based on environment variables and parse it in the case the editor contains flags and arguments for cases like Visual Studio Code. Additionally, there are no tests for the EditorName function and the fact it is loaded via `init` makes this difficult to test. Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> --- internal/prompter/prompter.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 7dd45bf5c..cd53d7199 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -234,9 +234,9 @@ func (p *accessiblePrompter) InputHostname() (string, error) { func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { var result string skipOption := "skip" - openOption := "open" + launchOption := "launch" options := []huh.Option[string]{ - huh.NewOption(fmt.Sprintf("Open Editor: %s", p.editorCmd), openOption), + huh.NewOption(fmt.Sprintf("Launch %s", surveyext.EditorName(p.editorCmd)), launchOption), } if blankAllowed { options = append(options, huh.NewOption("Skip", skipOption)) From 8cd39923fe273a059858b6e38f45cb4ba7d6a125 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 10 Apr 2025 17:09:22 -0600 Subject: [PATCH 082/147] test(prompter): fix race condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This test was trying to block on `expect`’ing a string at the same time the prompt was completed. This doesn't need to happen for this test. It should just check for the output from the Input prompt invocation. --- internal/prompter/accessible_prompter_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 60ae5162b..56096972d 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -108,10 +108,6 @@ func TestAccessiblePrompter(t *testing.T) { // Enter nothing _, err = console.SendLine("") require.NoError(t, err) - - // Expect the default value to be returned - _, err = console.ExpectString(dummyDefaultValue) - require.NoError(t, err) }() inputValue, err := p.Input("Enter some characters", dummyDefaultValue) From 70537de13260d714e5a0ef51c9525daa8fe1a217 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 10 Apr 2025 17:18:56 -0600 Subject: [PATCH 083/147] test(prompter): fix invalid comment --- internal/prompter/prompter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index cd53d7199..6ef61cf15 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -259,7 +259,7 @@ func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAl return "", nil } - // openOption was selected + // launchOption was selected text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr) if err != nil { return "", err From 0251a8dd6df25729c91eee8e6efc5a836f7fca79 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 11 Apr 2025 09:56:17 +0100 Subject: [PATCH 084/147] Explain why step logs are preferred Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/view.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 718d5b902..60e167024 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -677,6 +677,10 @@ func attachRunLog(rlz *zip.Reader, jobs []shared.Job) { func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error { for _, job := range jobs { + // To display a run log, we first try to compile it from individual step + // logs, because this way we can prepend lines with the corresponding + // step name. However, it's possible that we don't have the step logs, + // in which case we fall back to print the entire job run log. var hasStepLogs bool steps := job.Steps From f673b409f748957c345f655e4eb499d859d8adfe Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 11 Apr 2025 09:57:13 +0100 Subject: [PATCH 085/147] Replace `UNKNOWN` with `UNKNOWN STEP` in job run log Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/view.go | 2 +- pkg/cmd/run/view/view_test.go | 24 ++++++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 60e167024..1e60f535e 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -711,7 +711,7 @@ func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error { continue } - prefix := fmt.Sprintf("%s\tUNKNOWN\t", job.Name) + prefix := fmt.Sprintf("%s\tUNKNOWN STEP\t", job.Name) if err := printZIPFile(w, job.Log, prefix); err != nil { return err } diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index b5d619cd6..2205a9031 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -2298,27 +2298,27 @@ sad job quux the barf log line 3 `) var coolJobRunWithNoStepLogsLogOutput = heredoc.Doc(` -cool job with no step logs UNKNOWN log line 1 -cool job with no step logs UNKNOWN log line 2 -cool job with no step logs UNKNOWN log line 3 +cool job with no step logs UNKNOWN STEP log line 1 +cool job with no step logs UNKNOWN STEP log line 2 +cool job with no step logs UNKNOWN STEP log line 3 `) var legacyCoolJobRunWithNoStepLogsLogOutput = heredoc.Doc(` -legacy cool job with no step logs UNKNOWN log line 1 -legacy cool job with no step logs UNKNOWN log line 2 -legacy cool job with no step logs UNKNOWN log line 3 +legacy cool job with no step logs UNKNOWN STEP log line 1 +legacy cool job with no step logs UNKNOWN STEP log line 2 +legacy cool job with no step logs UNKNOWN STEP log line 3 `) var sadJobRunWithNoStepLogsLogOutput = heredoc.Doc(` -sad job with no step logs UNKNOWN log line 1 -sad job with no step logs UNKNOWN log line 2 -sad job with no step logs UNKNOWN log line 3 +sad job with no step logs UNKNOWN STEP log line 1 +sad job with no step logs UNKNOWN STEP log line 2 +sad job with no step logs UNKNOWN STEP log line 3 `) var legacySadJobRunWithNoStepLogsLogOutput = heredoc.Doc(` -legacy sad job with no step logs UNKNOWN log line 1 -legacy sad job with no step logs UNKNOWN log line 2 -legacy sad job with no step logs UNKNOWN log line 3 +legacy sad job with no step logs UNKNOWN STEP log line 1 +legacy sad job with no step logs UNKNOWN STEP log line 2 +legacy sad job with no step logs UNKNOWN STEP log line 3 `) var coolJobRunLogOutput = fmt.Sprintf("%s%s", fobTheBarzLogOutput, barfTheFobLogOutput) From 1bf1153c548fb0662be0835192b57676825c9c63 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 11 Apr 2025 10:07:31 +0100 Subject: [PATCH 086/147] Explain the `UNKNWON STEP` placeholder Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/view.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 1e60f535e..cec1d0376 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -711,6 +711,11 @@ func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error { continue } + // Here, we fall back to the job run log, which means we do not know + // the step name of lines. However, we want to keep the same line + // formatting to avoid breaking any code or script that rely on the + // tab-delimited formatting. So, an unknown-step placeholder is used + // instead of the actual step name. prefix := fmt.Sprintf("%s\tUNKNOWN STEP\t", job.Name) if err := printZIPFile(w, job.Log, prefix); err != nil { return err From d35236948cfd3a868e9ee0c1a5016b4cc979ac84 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 11 Apr 2025 10:17:52 +0100 Subject: [PATCH 087/147] Improve explanation for missing step logs Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/view.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index cec1d0376..ee8330a56 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -679,7 +679,8 @@ func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error { for _, job := range jobs { // To display a run log, we first try to compile it from individual step // logs, because this way we can prepend lines with the corresponding - // step name. However, it's possible that we don't have the step logs, + // step name. However, at the time of writing, logs are sometimes being + // served by a service that doesn’t include the step logs (none of them), // in which case we fall back to print the entire job run log. var hasStepLogs bool From 5adf3285ec24415046d970b518de1250cfb28f1a Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 11 Apr 2025 10:26:14 +0100 Subject: [PATCH 088/147] Explain when a negative number prefix appears Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/view.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index ee8330a56..dfde7efe1 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -653,6 +653,9 @@ func truncateAsUTF16(str string, max int) string { // the prefixed number is either: // - An ordinal and cannot be mapped to the corresponding job's ID. // - A negative integer which is the ID of the job in the old Actions service. +// The service right now tries to get logs and use an ordinal in a loop. +// However, if it doesn't get the logs, it falls back to an old service +// where the ID can apparently be negative. func attachRunLog(rlz *zip.Reader, jobs []shared.Job) { for i, job := range jobs { re := jobLogFilenameRegexp(job) From 940bd10a1d9cd3d2ba25d7491f81355d5b0cdb70 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 11 Apr 2025 12:39:09 +0100 Subject: [PATCH 089/147] Prefer normal job run log file over legacy one Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/view.go | 42 ++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index d46c2c9a9..2922fb602 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -551,9 +551,19 @@ func getJobNameForLogFilename(name string) string { return sanitizedJobName } +// A job run log file is a top-level .txt file whose name starts with an ordinal +// number; e.g., "0_jobname.txt". func jobLogFilenameRegexp(job shared.Job) *regexp.Regexp { sanitizedJobName := getJobNameForLogFilename(job.Name) - re := fmt.Sprintf(`^-?\d+_%s\.txt`, regexp.QuoteMeta(sanitizedJobName)) + re := fmt.Sprintf(`^\d+_%s\.txt`, regexp.QuoteMeta(sanitizedJobName)) + return regexp.MustCompile(re) +} + +// A legacy job run log file is a top-level .txt file whose name starts with a +// negative number which is the ID of the run; e.g., "-2147483648_jobname.txt". +func legacyJobLogFilenameRegexp(job shared.Job) *regexp.Regexp { + sanitizedJobName := getJobNameForLogFilename(job.Name) + re := fmt.Sprintf(`^-\d+_%s\.txt`, regexp.QuoteMeta(sanitizedJobName)) return regexp.MustCompile(re) } @@ -658,26 +668,30 @@ func truncateAsUTF16(str string, max int) string { // where the ID can apparently be negative. func attachRunLog(rlz *zip.Reader, jobs []shared.Job) { for i, job := range jobs { - re := jobLogFilenameRegexp(job) - for _, file := range rlz.File { - if re.MatchString(file.Name) { - jobs[i].Log = file - break - } + // the normal job run log file is preferred over the legacy one. So, we + // try to find the normal log file, and if we couldn't find it then we + // look for the legacy one, if any. + jobLog := matchFileInZIPArchive(rlz, jobLogFilenameRegexp(job)) + if jobLog == nil { + jobLog = matchFileInZIPArchive(rlz, legacyJobLogFilenameRegexp(job)) } + jobs[i].Log = jobLog for j, step := range job.Steps { - re := stepLogFilenameRegexp(job, step) - for _, file := range rlz.File { - if re.MatchString(file.Name) { - jobs[i].Steps[j].Log = file - break - } - } + jobs[i].Steps[j].Log = matchFileInZIPArchive(rlz, stepLogFilenameRegexp(job, step)) } } } +func matchFileInZIPArchive(zr *zip.Reader, re *regexp.Regexp) *zip.File { + for _, file := range zr.File { + if re.MatchString(file.Name) { + return file + } + } + return nil +} + func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error { for _, job := range jobs { // To display a run log, we first try to compile it from individual step From 2d21b4624ca71087aa393de91c5c51652b711645 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 11 Apr 2025 12:40:26 +0100 Subject: [PATCH 090/147] Test normal job run log is preferred over legacy one Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/fixtures/run_log.zip | Bin 8148 -> 8646 bytes pkg/cmd/run/view/view_test.go | 14 ++++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/run/view/fixtures/run_log.zip b/pkg/cmd/run/view/fixtures/run_log.zip index 60701d9254cbcacdda1d9aff426de744b6d574d7..425ba09ddce377dc26bffb4eaf7cdc2c99bc92ab 100644 GIT binary patch delta 408 zcmca&f6RG9i{RuvEF3 Date: Fri, 11 Apr 2025 12:41:31 +0100 Subject: [PATCH 091/147] Add `$` anchor to log file regexps Signed-off-by: Babak K. Shandiz This is to make sure we do not pick up the wrong file if there is a `.txt` sequence in the middle of a job name. --- pkg/cmd/run/view/view.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 2922fb602..b769028bc 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -555,7 +555,7 @@ func getJobNameForLogFilename(name string) string { // number; e.g., "0_jobname.txt". func jobLogFilenameRegexp(job shared.Job) *regexp.Regexp { sanitizedJobName := getJobNameForLogFilename(job.Name) - re := fmt.Sprintf(`^\d+_%s\.txt`, regexp.QuoteMeta(sanitizedJobName)) + re := fmt.Sprintf(`^\d+_%s\.txt$`, regexp.QuoteMeta(sanitizedJobName)) return regexp.MustCompile(re) } @@ -563,13 +563,13 @@ func jobLogFilenameRegexp(job shared.Job) *regexp.Regexp { // negative number which is the ID of the run; e.g., "-2147483648_jobname.txt". func legacyJobLogFilenameRegexp(job shared.Job) *regexp.Regexp { sanitizedJobName := getJobNameForLogFilename(job.Name) - re := fmt.Sprintf(`^-\d+_%s\.txt`, regexp.QuoteMeta(sanitizedJobName)) + re := fmt.Sprintf(`^-\d+_%s\.txt$`, regexp.QuoteMeta(sanitizedJobName)) return regexp.MustCompile(re) } func stepLogFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp { sanitizedJobName := getJobNameForLogFilename(job.Name) - re := fmt.Sprintf(`^%s\/%d_.*\.txt`, regexp.QuoteMeta(sanitizedJobName), step.Number) + re := fmt.Sprintf(`^%s\/%d_.*\.txt$`, regexp.QuoteMeta(sanitizedJobName), step.Number) return regexp.MustCompile(re) } From 48274f3118f2357e536dfabf3ee5ca42535fdcc0 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 11 Apr 2025 15:23:18 +0200 Subject: [PATCH 092/147] Document UNKNOWN STEP in run view --- pkg/cmd/run/view/view.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index d46c2c9a9..5b8a1da87 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -118,6 +118,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman This command does not support authenticating via fine grained PATs as it is not currently possible to create a PAT with the %[1]schecks:read%[1]s permission. + + Due to platform limitations, %[1]sgh%[1]s may not always be able to associate log lines with a + particular step in a job. In this case, the step name in the log output will be replaced with + %[1]sUNKNOWN STEP%[1]s. `, "`"), Args: cobra.MaximumNArgs(1), Example: heredoc.Doc(` From f337ce90a07284634ab1db67cf823fcbc8f25799 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 11 Apr 2025 14:51:39 +0100 Subject: [PATCH 093/147] Explain job log resolution reason Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/view/view.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index b769028bc..55be7f5f4 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -668,9 +668,10 @@ func truncateAsUTF16(str string, max int) string { // where the ID can apparently be negative. func attachRunLog(rlz *zip.Reader, jobs []shared.Job) { for i, job := range jobs { - // the normal job run log file is preferred over the legacy one. So, we - // try to find the normal log file, and if we couldn't find it then we - // look for the legacy one, if any. + // As a highest priority, we try to use the step logs first. We have seen zips that surprisingly contain + // step logs, normal job logs and legacy job logs. In this case, both job logs would be ignored. We have + // never seen a zip containing both job logs and no step logs, however, it may be possible. In that case + // let's prioritise the normal log over the legacy one. jobLog := matchFileInZIPArchive(rlz, jobLogFilenameRegexp(job)) if jobLog == nil { jobLog = matchFileInZIPArchive(rlz, legacyJobLogFilenameRegexp(job)) From 3aadd51096931d55e0e32253660da46af2d1314d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 11 Apr 2025 11:07:53 -0600 Subject: [PATCH 094/147] feat(iostreams): Option to opt out of spinners --- pkg/iostreams/iostreams.go | 18 +- .../iostreams_progress_indicator_test.go | 234 ++++++++++++++++++ 2 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 pkg/iostreams/iostreams_progress_indicator_test.go diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index f5e3c2aee..8608978c5 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "os/signal" + "slices" "strings" "sync" "time" @@ -294,9 +295,20 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) { return } - // https://github.com/briandowns/spinner#available-character-sets - dotStyle := spinner.CharSets[11] - sp := spinner.New(dotStyle, 120*time.Millisecond, spinner.WithWriter(s.ErrOut), spinner.WithColor("fgCyan")) + spinnerDisabledValue, spinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED") + falseyValues := []string{"false", "0", "no", ""} + + var spinnerStyle []string + if spinnerDisabledIsSet && !slices.Contains(falseyValues, spinnerDisabledValue) { + // This progress indicator must be readable by screen readers + spinnerStyle = []string{"Working..."} + } else { + // https://github.com/briandowns/spinner#available-character-sets + // ⣾ ⣷ ⣽ ⣻ ⡿ + spinnerStyle = spinner.CharSets[11] + } + + sp := spinner.New(spinnerStyle, 120*time.Millisecond, spinner.WithWriter(s.ErrOut), spinner.WithColor("fgCyan")) if label != "" { sp.Prefix = label + " " } diff --git a/pkg/iostreams/iostreams_progress_indicator_test.go b/pkg/iostreams/iostreams_progress_indicator_test.go new file mode 100644 index 000000000..c7c374811 --- /dev/null +++ b/pkg/iostreams/iostreams_progress_indicator_test.go @@ -0,0 +1,234 @@ +//go:build !windows + +package iostreams + +import ( + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/Netflix/go-expect" + "github.com/creack/pty" + "github.com/hinshun/vt10x" + "github.com/stretchr/testify/require" +) + +func TestStartProgressIndicatorWithLabel(t *testing.T) { + osOut := os.Stdout + defer func() { os.Stdout = osOut }() + // Why do we need a channel in these tests to implement a timeout instead of + // relying on expect's timeout? + // + // Well, expect's timeout is based on the maximum time of a single read + // from the console. This works in cases like prompting where we block + // waiting for input because the console is not ready to be read. + // But in this case, we are not blocking waiting for input and stdout + // can be constantly read. This means the timeout will never be reached + // in the event of a expectation failure. + // To fix this, we need to implement our own timeout that is based + // specifically on the total time spent reading the console and waiting + // for the target string instead of the max time for a single read + // from the console. + t.Run("progress indicator respects GH_SPINNER_DISABLED is true", func(t *testing.T) { + console := newTestVirtualTerminal(t) + io := newTestIOStreams(t, console) + t.Setenv("GH_SPINNER_DISABLED", "true") + + done := make(chan error) + + go func() { + _, err := console.ExpectString("Working...") + done <- err + }() + + io.StartProgressIndicatorWithLabel("") + defer io.StopProgressIndicator() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Test timed out waiting for progress indicator") + } + }) + + t.Run("progress indicator respects GH_SPINNER_DISABLED is false", func(t *testing.T) { + console := newTestVirtualTerminal(t) + io := newTestIOStreams(t, console) + t.Setenv("GH_SPINNER_DISABLED", "false") + + done := make(chan error) + + go func() { + _, err := console.ExpectString("⣾") + done <- err + }() + + io.StartProgressIndicatorWithLabel("") + defer io.StopProgressIndicator() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Test timed out waiting for progress indicator") + } + }) + + t.Run("progress indicator with GH_SPINNER_DISABLED shows label", func(t *testing.T) { + console := newTestVirtualTerminal(t) + io := newTestIOStreams(t, console) + t.Setenv("GH_SPINNER_DISABLED", "true") + progressIndicatorLabel := "downloading happiness" + + done := make(chan error) + + go func() { + _, err := console.ExpectString("Working...") + require.NoError(t, err) + _, err = console.ExpectString(progressIndicatorLabel) + done <- err + }() + + io.StartProgressIndicatorWithLabel(progressIndicatorLabel) + defer io.StopProgressIndicator() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Test timed out waiting for progress indicator") + } + }) + + t.Run("progress indicator shows label and spinner", func(t *testing.T) { + console := newTestVirtualTerminal(t) + io := newTestIOStreams(t, console) + progressIndicatorLabel := "downloading happiness" + + done := make(chan error) + + go func() { + _, err := console.ExpectString(progressIndicatorLabel) + require.NoError(t, err) + _, err = console.ExpectString("⣾") + done <- err + }() + + io.StartProgressIndicatorWithLabel(progressIndicatorLabel) + defer io.StopProgressIndicator() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Test timed out waiting for progress indicator") + } + }) +} + +func newTestVirtualTerminal(t *testing.T) *expect.Console { + t.Helper() + + // Create a PTY and hook up a virtual terminal emulator + ptm, pts, err := pty.Open() + require.NoError(t, err) + + term := vt10x.New(vt10x.WithWriter(pts)) + + // Create a console via Expect that allows scripting against the terminal + consoleOpts := []expect.ConsoleOpt{ + expect.WithStdin(ptm), + expect.WithStdout(term), + expect.WithCloser(ptm, pts), + failOnExpectError(t), + failOnSendError(t), + expect.WithDefaultTimeout(time.Second), + } + + console, err := expect.NewConsole(consoleOpts...) + require.NoError(t, err) + t.Cleanup(func() { testCloser(t, console) }) + + return console +} + +func newTestIOStreams(t *testing.T, console *expect.Console) *IOStreams { + t.Helper() + + in := console.Tty() + out := console.Tty() + errOut := console.Tty() + + // Because the briandowns/spinner checks os.Stdout directly, + // we need this hack to trick it into allowing the spinner to print... + os.Stdout = out + + io := &IOStreams{ + In: in, + Out: out, + ErrOut: errOut, + term: fakeTerm{}, + } + io.progressIndicatorEnabled = true + return io +} + +// failOnExpectError adds an observer that will fail the test in a standardised way +// if any expectation on the command output fails, without requiring an explicit +// assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnExpectError(t *testing.T) expect.ConsoleOpt { + t.Helper() + return expect.WithExpectObserver( + func(matchers []expect.Matcher, buf string, err error) { + t.Helper() + + if err == nil { + return + } + + if len(matchers) == 0 { + t.Fatalf("Error occurred while matching %q: %s\n", buf, err) + } + + var criteria []string + for _, matcher := range matchers { + criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria())) + } + t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err) + }, + ) +} + +// failOnSendError adds an observer that will fail the test in a standardised way +// if any sending of input fails, without requiring an explicit assertion. +// +// Use WithRelaxedIO to disable this behaviour. +func failOnSendError(t *testing.T) expect.ConsoleOpt { + t.Helper() + return expect.WithSendObserver( + func(msg string, n int, err error) { + t.Helper() + + if err != nil { + t.Fatalf("Failed to send %q: %s\n", msg, err) + } + if len(msg) != n { + t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg) + } + }, + ) +} + +// testCloser is a helper to fail the test if a Closer fails to close. +func testCloser(t *testing.T, closer io.Closer) { + t.Helper() + if err := closer.Close(); err != nil { + t.Errorf("Close failed: %s", err) + } +} From 7293c1651ecfd45e8fca35eb07ba400e5c2d305a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 14:07:09 +0000 Subject: [PATCH 095/147] chore(deps): bump github.com/sigstore/sigstore-go from 0.7.1 to 0.7.2 Bumps [github.com/sigstore/sigstore-go](https://github.com/sigstore/sigstore-go) from 0.7.1 to 0.7.2. - [Release notes](https://github.com/sigstore/sigstore-go/releases) - [Commits](https://github.com/sigstore/sigstore-go/compare/v0.7.1...v0.7.2) --- updated-dependencies: - dependency-name: github.com/sigstore/sigstore-go dependency-version: 0.7.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e0f76dce8..8fe69cc84 100644 --- a/go.mod +++ b/go.mod @@ -44,7 +44,7 @@ require ( github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc github.com/sigstore/protobuf-specs v0.4.1 - github.com/sigstore/sigstore-go v0.7.1 + github.com/sigstore/sigstore-go v0.7.2 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index 5441c96c5..63a1cdd77 100644 --- a/go.sum +++ b/go.sum @@ -452,8 +452,8 @@ github.com/sigstore/rekor v1.3.9 h1:sUjRpKVh/hhgqGMs0t+TubgYsksArZ6poLEC3MsGAzU= github.com/sigstore/rekor v1.3.9/go.mod h1:xThNUhm6eNEmkJ/SiU/FVU7pLY2f380fSDZFsdDWlcM= github.com/sigstore/sigstore v1.9.1 h1:bNMsfFATsMPaagcf+uppLk4C9rQZ2dh5ysmCxQBYWaw= github.com/sigstore/sigstore v1.9.1/go.mod h1:zUoATYzR1J3rLNp3jmp4fzIJtWdhC3ZM6MnpcBtnsE4= -github.com/sigstore/sigstore-go v0.7.1 h1:lyzi3AjO6+BHc5zCf9fniycqPYOt3RaC08M/FRmQhVY= -github.com/sigstore/sigstore-go v0.7.1/go.mod h1:AIRj4I3LC82qd07VFm3T2zXYiddxeBV1k/eoS8nTz0E= +github.com/sigstore/sigstore-go v0.7.2 h1:CN4xPasChSEb0QBMxMW5dLcXdA9KD4QiRyVnMkhXj6U= +github.com/sigstore/sigstore-go v0.7.2/go.mod h1:AIRj4I3LC82qd07VFm3T2zXYiddxeBV1k/eoS8nTz0E= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.1 h1:/YcNq687WnXpIRXl04nLfJX741G4iW+w+7Nem2Zy0f4= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.1/go.mod h1:ApL9RpKsi7gkSYN0bMNdm/3jZ9EefxMmfYHfUmq2ZYM= github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.1 h1:FnusXyTIInnwfIOzzl5PFilRm1I97dxMSOcCkZBu9Kc= From 3eba461afb2746576cd26ed391683494c4f48dd5 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 14 Apr 2025 15:07:11 -0600 Subject: [PATCH 096/147] fix(iostreams): spinner env var is now in factory - Move env var evaluation to the factory defaults. - Use only the label for the spinner instead of adding "working..." every time. "working..." remains the default when no label is provided. --- pkg/cmd/factory/default.go | 7 +++ pkg/cmd/factory/default_test.go | 50 +++++++++++++++++++ pkg/iostreams/iostreams.go | 33 ++++++++---- .../iostreams_progress_indicator_test.go | 18 +++---- 4 files changed, 88 insertions(+), 20 deletions(-) diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 6286c999d..7a45efeb4 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "regexp" + "slices" "time" "github.com/cli/cli/v2/api" @@ -283,6 +284,12 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams { io.SetNeverPrompt(true) } + ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED") + falseyValues := []string{"false", "0", "no", ""} + if ghSpinnerDisabledIsSet && !slices.Contains(falseyValues, ghSpinnerDisabledValue) { + io.SetSpinnerDisabled(true) + } + // Pager precedence // 1. GH_PAGER // 2. pager from config diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index c0275d1de..aff833d4c 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -432,6 +432,56 @@ func Test_ioStreams_prompt(t *testing.T) { } } +func Test_ioStreams_spinnerDisabled(t *testing.T) { + tests := []struct { + name string + spinnerDisabled bool + env map[string]string + }{ + { + name: "default config", + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = 0", + env: map[string]string{"GH_SPINNER_DISABLED": "0"}, + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = false", + env: map[string]string{"GH_SPINNER_DISABLED": "false"}, + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = no", + env: map[string]string{"GH_SPINNER_DISABLED": "no"}, + spinnerDisabled: false, + }, + { + name: "spinner enabled via GH_SPINNER_DISABLED env var = 1", + env: map[string]string{"GH_SPINNER_DISABLED": "1"}, + spinnerDisabled: true, + }, + { + name: "spinner enabled via GH_SPINNER_DISABLED env var = true", + env: map[string]string{"GH_SPINNER_DISABLED": "true"}, + spinnerDisabled: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != nil { + for k, v := range tt.env { + t.Setenv(k, v) + } + } + f := New("1") + io := ioStreams(f) + assert.Equal(t, tt.spinnerDisabled, io.GetSpinnerDisabled()) + }) + } +} + func Test_ioStreams_colorLabels(t *testing.T) { tests := []struct { name string diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 8608978c5..07d7aa27f 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -8,7 +8,6 @@ import ( "os" "os/exec" "os/signal" - "slices" "strings" "sync" "time" @@ -79,7 +78,8 @@ type IOStreams struct { pagerCommand string pagerProcess *os.Process - neverPrompt bool + neverPrompt bool + spinnerDisabled bool TempFileOverride *os.File } @@ -274,6 +274,14 @@ func (s *IOStreams) SetNeverPrompt(v bool) { s.neverPrompt = v } +func (s *IOStreams) GetSpinnerDisabled() bool { + return s.spinnerDisabled +} + +func (s *IOStreams) SetSpinnerDisabled(v bool) { + s.spinnerDisabled = v +} + func (s *IOStreams) StartProgressIndicator() { s.StartProgressIndicatorWithLabel("") } @@ -295,13 +303,20 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) { return } - spinnerDisabledValue, spinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED") - falseyValues := []string{"false", "0", "no", ""} - var spinnerStyle []string - if spinnerDisabledIsSet && !slices.Contains(falseyValues, spinnerDisabledValue) { - // This progress indicator must be readable by screen readers - spinnerStyle = []string{"Working..."} + if s.spinnerDisabled { + // Default label when spinner disabled is "Working..." + if label == "" { + label = "Working..." + } + + // Add an ellipsis to the label if it doesn't already have one. + ellipsis := "..." + if !strings.HasSuffix(label, ellipsis) { + label = label + ellipsis + } + + spinnerStyle = []string{label} } else { // https://github.com/briandowns/spinner#available-character-sets // ⣾ ⣷ ⣽ ⣻ ⡿ @@ -309,7 +324,7 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) { } sp := spinner.New(spinnerStyle, 120*time.Millisecond, spinner.WithWriter(s.ErrOut), spinner.WithColor("fgCyan")) - if label != "" { + if label != "" && !s.spinnerDisabled { sp.Prefix = label + " " } diff --git a/pkg/iostreams/iostreams_progress_indicator_test.go b/pkg/iostreams/iostreams_progress_indicator_test.go index c7c374811..3cd6eaf65 100644 --- a/pkg/iostreams/iostreams_progress_indicator_test.go +++ b/pkg/iostreams/iostreams_progress_indicator_test.go @@ -34,8 +34,7 @@ func TestStartProgressIndicatorWithLabel(t *testing.T) { // from the console. t.Run("progress indicator respects GH_SPINNER_DISABLED is true", func(t *testing.T) { console := newTestVirtualTerminal(t) - io := newTestIOStreams(t, console) - t.Setenv("GH_SPINNER_DISABLED", "true") + io := newTestIOStreams(t, console, true) done := make(chan error) @@ -57,8 +56,7 @@ func TestStartProgressIndicatorWithLabel(t *testing.T) { t.Run("progress indicator respects GH_SPINNER_DISABLED is false", func(t *testing.T) { console := newTestVirtualTerminal(t) - io := newTestIOStreams(t, console) - t.Setenv("GH_SPINNER_DISABLED", "false") + io := newTestIOStreams(t, console, false) done := make(chan error) @@ -80,16 +78,13 @@ func TestStartProgressIndicatorWithLabel(t *testing.T) { t.Run("progress indicator with GH_SPINNER_DISABLED shows label", func(t *testing.T) { console := newTestVirtualTerminal(t) - io := newTestIOStreams(t, console) - t.Setenv("GH_SPINNER_DISABLED", "true") + io := newTestIOStreams(t, console, true) progressIndicatorLabel := "downloading happiness" done := make(chan error) go func() { - _, err := console.ExpectString("Working...") - require.NoError(t, err) - _, err = console.ExpectString(progressIndicatorLabel) + _, err := console.ExpectString(progressIndicatorLabel + "...") done <- err }() @@ -106,7 +101,7 @@ func TestStartProgressIndicatorWithLabel(t *testing.T) { t.Run("progress indicator shows label and spinner", func(t *testing.T) { console := newTestVirtualTerminal(t) - io := newTestIOStreams(t, console) + io := newTestIOStreams(t, console, false) progressIndicatorLabel := "downloading happiness" done := make(chan error) @@ -156,7 +151,7 @@ func newTestVirtualTerminal(t *testing.T) *expect.Console { return console } -func newTestIOStreams(t *testing.T, console *expect.Console) *IOStreams { +func newTestIOStreams(t *testing.T, console *expect.Console, spinnerDisabled bool) *IOStreams { t.Helper() in := console.Tty() @@ -174,6 +169,7 @@ func newTestIOStreams(t *testing.T, console *expect.Console) *IOStreams { term: fakeTerm{}, } io.progressIndicatorEnabled = true + io.SetSpinnerDisabled(spinnerDisabled) return io } From f283d6d11ce1c3d15c3cf55a93d2c1a6fa1fcdd6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 14 Apr 2025 19:30:05 -0600 Subject: [PATCH 097/147] feat(iostreams): textual progress indicator does not clear screen This bypasses the `spinner` package for the textual progress indicator for users with the spinner disabled out of concerns for accessibility, specifically with screen readers: - The `spinner` package will continuously re-draw the screen. I wasn't able to have this cause problems with my Mac screen reader, but it's nonetheless a concern that other screen readers may not handle this screen re-drawing well. - The `spinner` package clears any progress indicator messages from the screen when stopping the progress indicator or changing its label. This is a problem because it interrupts screen readers and leaves no way to recover what the loading message was by scrolling up in the terminal. NOTE: this new implementation still interrupts the screen reader when the a new label is printed, but it does not clear the screen. This makes the loading messages recoverable, at least. --- pkg/iostreams/iostreams.go | 47 +++++++++++-------- .../iostreams_progress_indicator_test.go | 24 ++++++++++ 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 07d7aa27f..cfc0da170 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -291,6 +291,11 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) { return } + if s.spinnerDisabled { + s.startTextualProgressIndicator(label) + return + } + s.progressIndicatorMu.Lock() defer s.progressIndicatorMu.Unlock() @@ -303,28 +308,12 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) { return } - var spinnerStyle []string - if s.spinnerDisabled { - // Default label when spinner disabled is "Working..." - if label == "" { - label = "Working..." - } - - // Add an ellipsis to the label if it doesn't already have one. - ellipsis := "..." - if !strings.HasSuffix(label, ellipsis) { - label = label + ellipsis - } - - spinnerStyle = []string{label} - } else { - // https://github.com/briandowns/spinner#available-character-sets - // ⣾ ⣷ ⣽ ⣻ ⡿ - spinnerStyle = spinner.CharSets[11] - } + // https://github.com/briandowns/spinner#available-character-sets + // ⣾ ⣷ ⣽ ⣻ ⡿ + spinnerStyle := spinner.CharSets[11] sp := spinner.New(spinnerStyle, 120*time.Millisecond, spinner.WithWriter(s.ErrOut), spinner.WithColor("fgCyan")) - if label != "" && !s.spinnerDisabled { + if label != "" { sp.Prefix = label + " " } @@ -332,6 +321,24 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) { s.progressIndicator = sp } +func (s *IOStreams) startTextualProgressIndicator(label string) { + s.progressIndicatorMu.Lock() + defer s.progressIndicatorMu.Unlock() + + // Default label when spinner disabled is "Working..." + if label == "" { + label = "Working..." + } + + // Add an ellipsis to the label if it doesn't already have one. + ellipsis := "..." + if !strings.HasSuffix(label, ellipsis) { + label = label + ellipsis + } + + fmt.Fprintf(s.ErrOut, "%s%s", s.ColorScheme().Cyan(label), "\n") +} + func (s *IOStreams) StopProgressIndicator() { s.progressIndicatorMu.Lock() defer s.progressIndicatorMu.Unlock() diff --git a/pkg/iostreams/iostreams_progress_indicator_test.go b/pkg/iostreams/iostreams_progress_indicator_test.go index 3cd6eaf65..42eb5e56a 100644 --- a/pkg/iostreams/iostreams_progress_indicator_test.go +++ b/pkg/iostreams/iostreams_progress_indicator_test.go @@ -123,6 +123,30 @@ func TestStartProgressIndicatorWithLabel(t *testing.T) { t.Fatal("Test timed out waiting for progress indicator") } }) + + t.Run("multiple indicators with GH_SPINNER_DISABLED shows current label", func(t *testing.T) { + console := newTestVirtualTerminal(t) + io := newTestIOStreams(t, console, true) + progressIndicatorLabel1 := "downloading happiness" + progressIndicatorLabel2 := "downloading sadness" + done := make(chan error) + go func() { + _, err := console.ExpectString(progressIndicatorLabel1 + "...") + require.NoError(t, err) + _, err = console.ExpectString(progressIndicatorLabel2 + "...") + done <- err + }() + io.StartProgressIndicatorWithLabel(progressIndicatorLabel1) + defer io.StopProgressIndicator() + io.StartProgressIndicatorWithLabel(progressIndicatorLabel2) + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Test timed out waiting for progress indicator") + } + }) } func newTestVirtualTerminal(t *testing.T) *expect.Console { From 15ff7ba465d08caabf8ed37530fed4fa1114014e Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Tue, 15 Apr 2025 08:00:47 -0400 Subject: [PATCH 098/147] Restore result.Total logic, fix formatting This change restores the original logic of passing the search total count logic as is to the result. Additionally, this undoes some of the contributor's formatting changes that increase the changed lines to review. --- pkg/search/searcher.go | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 155484ac9..6859c2149 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -87,13 +87,11 @@ func (s searcher) Code(query Query) (CodeResult, error) { return result, err } - result.IncompleteResults = page.IncompleteResults - // If we're going to reach the requested limit, only add that many items, // otherwise add all the results. itemsToAdd := min(len(page.Items), toRetrieve) - - result.Total += itemsToAdd + result.IncompleteResults = page.IncompleteResults + result.Total = page.Total result.Items = append(result.Items, page.Items[:itemsToAdd]...) toRetrieve = toRetrieve - itemsToAdd } @@ -122,11 +120,9 @@ func (s searcher) Commits(query Query) (CommitsResult, error) { return result, err } - result.IncompleteResults = page.IncompleteResults - itemsToAdd := min(len(page.Items), toRetrieve) - - result.Total += itemsToAdd + result.IncompleteResults = page.IncompleteResults + result.Total = page.Total result.Items = append(result.Items, page.Items[:itemsToAdd]...) toRetrieve = toRetrieve - itemsToAdd } @@ -154,10 +150,8 @@ func (s searcher) Repositories(query Query) (RepositoriesResult, error) { return result, err } - result.IncompleteResults = page.IncompleteResults - itemsToAdd := min(len(page.Items), toRetrieve) - + result.IncompleteResults = page.IncompleteResults result.Total += itemsToAdd result.Items = append(result.Items, page.Items[:itemsToAdd]...) toRetrieve = toRetrieve - itemsToAdd @@ -186,10 +180,8 @@ func (s searcher) Issues(query Query) (IssuesResult, error) { return result, err } - result.IncompleteResults = page.IncompleteResults - itemsToAdd := min(len(page.Items), toRetrieve) - + result.IncompleteResults = page.IncompleteResults result.Total += itemsToAdd result.Items = append(result.Items, page.Items[:itemsToAdd]...) toRetrieve = toRetrieve - itemsToAdd From f928cb1e19e8c642a24331a1aef70ad21c510e73 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Tue, 15 Apr 2025 08:36:45 -0400 Subject: [PATCH 099/147] Fix remaining search logic totals --- pkg/search/searcher.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 6859c2149..4d2154a4a 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -152,7 +152,7 @@ func (s searcher) Repositories(query Query) (RepositoriesResult, error) { itemsToAdd := min(len(page.Items), toRetrieve) result.IncompleteResults = page.IncompleteResults - result.Total += itemsToAdd + result.Total = page.Total result.Items = append(result.Items, page.Items[:itemsToAdd]...) toRetrieve = toRetrieve - itemsToAdd } @@ -182,7 +182,7 @@ func (s searcher) Issues(query Query) (IssuesResult, error) { itemsToAdd := min(len(page.Items), toRetrieve) result.IncompleteResults = page.IncompleteResults - result.Total += itemsToAdd + result.Total = page.Total result.Items = append(result.Items, page.Items[:itemsToAdd]...) toRetrieve = toRetrieve - itemsToAdd } From bdfd51ca7fc796e034a69fd4044c06b41f43ebea Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Tue, 15 Apr 2025 14:43:10 -0400 Subject: [PATCH 100/147] Fix search tests around totals, initialization This commit is focused on fixing the `searcher` tests for a few reasons: 1. Correcting the `.Total` logic in the previous commit caused changes to tests to fail 2. Tests involving results that exceed the max per page have been improved with new initialize helper, allowing testing table scenarios to be self contained 3. Tests that stub JSON response payloads have been standardized on maps rather than GitHub type primitives (Repository, Issue, Commit, Code, etc) 4. Tests had some minor formatting changes to make them easier to understand and maintain --- pkg/search/searcher_test.go | 355 ++++++++++++++++++------------------ 1 file changed, 173 insertions(+), 182 deletions(-) diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go index 1ab8379dd..503751e3e 100644 --- a/pkg/search/searcher_test.go +++ b/pkg/search/searcher_test.go @@ -1,6 +1,7 @@ package search import ( + "fmt" "net/http" "net/url" "strconv" @@ -27,21 +28,6 @@ func TestSearcherCode(t *testing.T) { "q": []string{"keyword language:go"}, } - multiplePagesTotalItems := make([]Code, 0, 110) - multiplePagesFirstResItems := make([]Code, 0, 100) - multiplePagesSecondResItems := make([]Code, 0, 10) - for i := range 110 { - commit := Code{Name: "name" + strconv.Itoa(i) + ".go"} - - multiplePagesTotalItems = append(multiplePagesTotalItems, commit) - - if i < 100 { - multiplePagesFirstResItems = append(multiplePagesFirstResItems, commit) - } else { - multiplePagesSecondResItems = append(multiplePagesSecondResItems, commit) - } - } - tests := []struct { name string host string @@ -100,25 +86,22 @@ func TestSearcherCode(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/code", values) - firstRes := httpmock.JSONResponse(CodeResult{ - IncompleteResults: false, - Items: []Code{{Name: "file.go"}}, - Total: 1, - }, - ) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []Code{{Name: "file.go"}}, + }) firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/code", url.Values{ "page": []string{"2"}, "per_page": []string{"30"}, "q": []string{"keyword language:go"}, - }, - ) - secondRes := httpmock.JSONResponse(CodeResult{ - IncompleteResults: false, - Items: []Code{{Name: "file2.go"}}, - Total: 1, - }, - ) + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []Code{{Name: "file2.go"}}, + }) reg.Register(firstReq, firstRes) reg.Register(secondReq, secondRes) }, @@ -135,8 +118,12 @@ func TestSearcherCode(t *testing.T) { }, result: CodeResult{ IncompleteResults: false, - Items: multiplePagesTotalItems, - Total: 110, + Items: initialize(0, 110, func(i int) Code { + return Code{ + Name: fmt.Sprintf("name%d.go", i), + } + }), + Total: 110, }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/code", url.Values{ @@ -144,25 +131,30 @@ func TestSearcherCode(t *testing.T) { "per_page": []string{"100"}, "q": []string{"keyword language:go"}, }) - firstRes := httpmock.JSONResponse(CodeResult{ - IncompleteResults: false, - Items: multiplePagesFirstResItems, - Total: 100, - }, - ) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 110, + "items": initialize(0, 100, func(i int) interface{} { + return map[string]interface{}{ + "name": fmt.Sprintf("name%d.go", i), + } + }), + }) firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/code", url.Values{ "page": []string{"2"}, "per_page": []string{"100"}, "q": []string{"keyword language:go"}, - }, - ) - secondRes := httpmock.JSONResponse(CodeResult{ - IncompleteResults: false, - Items: multiplePagesSecondResItems, - Total: 10, - }, - ) + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 110, + "items": initialize(100, 110, func(i int) interface{} { + return map[string]interface{}{ + "name": fmt.Sprintf("name%d.go", i), + } + }), + }) reg.Register(firstReq, firstRes) reg.Register(secondReq, secondRes) }, @@ -241,21 +233,6 @@ func TestSearcherCommits(t *testing.T) { "q": []string{"keyword author:foobar committer-date:>2021-02-28"}, } - multiplePagesTotalItems := make([]Commit, 0, 110) - multiplePagesFirstResItems := make([]Commit, 0, 100) - multiplePagesSecondResItems := make([]Commit, 0, 10) - for i := range 110 { - commit := Commit{Sha: strconv.Itoa(i)} - - multiplePagesTotalItems = append(multiplePagesTotalItems, commit) - - if i < 100 { - multiplePagesFirstResItems = append(multiplePagesFirstResItems, commit) - } else { - multiplePagesSecondResItems = append(multiplePagesSecondResItems, commit) - } - } - tests := []struct { name string host string @@ -296,10 +273,10 @@ func TestSearcherCommits(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "api/v3/search/commits", values), - httpmock.JSONResponse(CommitsResult{ - IncompleteResults: false, - Items: []Commit{{Sha: "abc"}}, - Total: 1, + httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 1, + "items": []Commit{{Sha: "abc"}}, }), ) }, @@ -314,12 +291,11 @@ func TestSearcherCommits(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/commits", values) - firstRes := httpmock.JSONResponse(CommitsResult{ - IncompleteResults: false, - Items: []Commit{{Sha: "abc"}}, - Total: 1, - }, - ) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []Commit{{Sha: "abc"}}, + }) firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ "page": []string{"2"}, @@ -327,14 +303,12 @@ func TestSearcherCommits(t *testing.T) { "order": []string{"desc"}, "sort": []string{"committer-date"}, "q": []string{"keyword author:foobar committer-date:>2021-02-28"}, - }, - ) - secondRes := httpmock.JSONResponse(CommitsResult{ - IncompleteResults: false, - Items: []Commit{{Sha: "def"}}, - Total: 1, - }, - ) + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []Commit{{Sha: "def"}}, + }) reg.Register(firstReq, firstRes) reg.Register(secondReq, secondRes) }, @@ -354,8 +328,12 @@ func TestSearcherCommits(t *testing.T) { }, result: CommitsResult{ IncompleteResults: false, - Items: multiplePagesTotalItems, - Total: 110, + Items: initialize(0, 110, func(i int) Commit { + return Commit{ + Sha: strconv.Itoa(i), + } + }), + Total: 110, }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ @@ -365,12 +343,15 @@ func TestSearcherCommits(t *testing.T) { "sort": []string{"committer-date"}, "q": []string{"keyword author:foobar committer-date:>2021-02-28"}, }) - firstRes := httpmock.JSONResponse(CommitsResult{ - IncompleteResults: false, - Items: multiplePagesFirstResItems, - Total: 100, - }, - ) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 110, + "items": initialize(0, 100, func(i int) Commit { + return Commit{ + Sha: strconv.Itoa(i), + } + }), + }) firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ "page": []string{"2"}, @@ -378,14 +359,16 @@ func TestSearcherCommits(t *testing.T) { "order": []string{"desc"}, "sort": []string{"committer-date"}, "q": []string{"keyword author:foobar committer-date:>2021-02-28"}, - }, - ) - secondRes := httpmock.JSONResponse(CommitsResult{ - IncompleteResults: false, - Items: multiplePagesSecondResItems, - Total: 10, - }, - ) + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 110, + "items": initialize(100, 110, func(i int) Commit { + return Commit{ + Sha: strconv.Itoa(i), + } + }), + }) reg.Register(firstReq, firstRes) reg.Register(secondReq, secondRes) }, @@ -395,8 +378,8 @@ func TestSearcherCommits(t *testing.T) { query: query, wantErr: true, errMsg: heredoc.Doc(` - Invalid search query "keyword author:foobar committer-date:>2021-02-28". - "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), + Invalid search query "keyword author:foobar committer-date:>2021-02-28". + "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "search/commits", values), @@ -464,25 +447,6 @@ func TestSearcherRepositories(t *testing.T) { "q": []string{"keyword stars:>=5 topic:topic"}, } - multiplePagesTotalItems := make([]Repository, 0, 110) - multiplePagesFirstResItems := make([]any, 0, 100) - multiplePagesSecondResItems := make([]any, 0, 10) - for i := range 110 { - num := strconv.Itoa(i) - - multiplePagesTotalItems = append(multiplePagesTotalItems, Repository{Name: "name" + num}) - - if i < 100 { - multiplePagesFirstResItems = append(multiplePagesFirstResItems, map[string]any{ - "name": "name" + num, - }) - } else { - multiplePagesSecondResItems = append(multiplePagesSecondResItems, map[string]any{ - "name": "name" + num, - }) - } - } - tests := []struct { name string host string @@ -551,7 +515,7 @@ func TestSearcherRepositories(t *testing.T) { firstReq := httpmock.QueryMatcher("GET", "search/repositories", values) firstRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 1, + "total_count": 2, "items": []interface{}{ map[string]interface{}{ "name": "test", @@ -565,11 +529,10 @@ func TestSearcherRepositories(t *testing.T) { "order": []string{"desc"}, "sort": []string{"stars"}, "q": []string{"keyword stars:>=5 topic:topic"}, - }, - ) + }) secondRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 1, + "total_count": 2, "items": []interface{}{ map[string]interface{}{ "name": "cli", @@ -595,8 +558,12 @@ func TestSearcherRepositories(t *testing.T) { }, result: RepositoriesResult{ IncompleteResults: false, - Items: multiplePagesTotalItems, - Total: 110, + Items: initialize(0, 110, func(i int) Repository { + return Repository{ + Name: fmt.Sprintf("name%d", i), + } + }), + Total: 110, }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{ @@ -608,8 +575,12 @@ func TestSearcherRepositories(t *testing.T) { }) firstRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 100, - "items": multiplePagesFirstResItems, + "total_count": 110, + "items": initialize(0, 100, func(i int) interface{} { + return map[string]interface{}{ + "name": fmt.Sprintf("name%d", i), + } + }), }) firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{ @@ -621,8 +592,12 @@ func TestSearcherRepositories(t *testing.T) { }) secondRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 10, - "items": multiplePagesSecondResItems, + "total_count": 110, + "items": initialize(100, 110, func(i int) interface{} { + return map[string]interface{}{ + "name": fmt.Sprintf("name%d", i), + } + }), }) reg.Register(firstReq, firstRes) reg.Register(secondReq, secondRes) @@ -633,8 +608,8 @@ func TestSearcherRepositories(t *testing.T) { query: query, wantErr: true, errMsg: heredoc.Doc(` - Invalid search query "keyword stars:>=5 topic:topic". - "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), + Invalid search query "keyword stars:>=5 topic:topic". + "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "search/repositories", values), @@ -702,21 +677,6 @@ func TestSearcherIssues(t *testing.T) { "q": []string{"keyword is:locked is:public language:go"}, } - multiplePagesTotalItems := make([]Issue, 0, 110) - multiplePagesFirstResItems := make([]Issue, 0, 100) - multiplePagesSecondResItems := make([]Issue, 0, 10) - for i := range 110 { - issue := Issue{Number: i} - - multiplePagesTotalItems = append(multiplePagesTotalItems, issue) - - if i < 100 { - multiplePagesFirstResItems = append(multiplePagesFirstResItems, issue) - } else { - multiplePagesSecondResItems = append(multiplePagesSecondResItems, issue) - } - } - tests := []struct { name string host string @@ -737,10 +697,14 @@ func TestSearcherIssues(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "search/issues", values), - httpmock.JSONResponse(IssuesResult{ - IncompleteResults: false, - Items: []Issue{{Number: 1234}}, - Total: 1, + httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 1, + "items": []interface{}{ + map[string]interface{}{ + "number": 1234, + }, + }, }), ) }, @@ -757,10 +721,14 @@ func TestSearcherIssues(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "api/v3/search/issues", values), - httpmock.JSONResponse(IssuesResult{ - IncompleteResults: false, - Items: []Issue{{Number: 1234}}, - Total: 1, + httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 1, + "items": []interface{}{ + map[string]interface{}{ + "number": 1234, + }, + }, }), ) }, @@ -775,12 +743,15 @@ func TestSearcherIssues(t *testing.T) { }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/issues", values) - firstRes := httpmock.JSONResponse(IssuesResult{ - IncompleteResults: false, - Items: []Issue{{Number: 1234}}, - Total: 1, - }, - ) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "number": 1234, + }, + }, + }) firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{ "page": []string{"2"}, @@ -788,14 +759,16 @@ func TestSearcherIssues(t *testing.T) { "order": []string{"desc"}, "sort": []string{"comments"}, "q": []string{"keyword is:locked is:public language:go"}, - }, - ) - secondRes := httpmock.JSONResponse(IssuesResult{ - IncompleteResults: false, - Items: []Issue{{Number: 5678}}, - Total: 1, - }, - ) + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 2, + "items": []interface{}{ + map[string]interface{}{ + "number": 5678, + }, + }, + }) reg.Register(firstReq, firstRes) reg.Register(secondReq, secondRes) }, @@ -815,8 +788,12 @@ func TestSearcherIssues(t *testing.T) { }, result: IssuesResult{ IncompleteResults: false, - Items: multiplePagesTotalItems, - Total: 110, + Items: initialize(0, 110, func(i int) Issue { + return Issue{ + Number: i, + } + }), + Total: 110, }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{ @@ -826,12 +803,15 @@ func TestSearcherIssues(t *testing.T) { "sort": []string{"comments"}, "q": []string{"keyword is:locked is:public language:go"}, }) - firstRes := httpmock.JSONResponse(IssuesResult{ - IncompleteResults: false, - Items: multiplePagesFirstResItems, - Total: 100, - }, - ) + firstRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 110, + "items": initialize(0, 100, func(i int) interface{} { + return map[string]interface{}{ + "number": i, + } + }), + }) firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{ "page": []string{"2"}, @@ -839,14 +819,16 @@ func TestSearcherIssues(t *testing.T) { "order": []string{"desc"}, "sort": []string{"comments"}, "q": []string{"keyword is:locked is:public language:go"}, - }, - ) - secondRes := httpmock.JSONResponse(IssuesResult{ - IncompleteResults: false, - Items: multiplePagesSecondResItems, - Total: 10, - }, - ) + }) + secondRes := httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 110, + "items": initialize(100, 110, func(i int) interface{} { + return map[string]interface{}{ + "number": i, + } + }), + }) reg.Register(firstReq, firstRes) reg.Register(secondReq, secondRes) }, @@ -856,8 +838,8 @@ func TestSearcherIssues(t *testing.T) { query: query, wantErr: true, errMsg: heredoc.Doc(` - Invalid search query "keyword is:locked is:public language:go". - "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), + Invalid search query "keyword is:locked is:public language:go". + "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "search/issues", values), @@ -945,3 +927,12 @@ func TestSearcherURL(t *testing.T) { }) } } + +// initialize generate slices over a range for test scenarios using the provided initializer. +func initialize[T any](start int, stop int, initializer func(i int) T) []T { + results := make([]T, 0, (stop - start)) + for i := start; i < stop; i++ { + results = append(results, initializer(i)) + } + return results +} From b61b2298d28cb0eff1d098f1ab9efbe6882042fb Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:13:56 -0700 Subject: [PATCH 101/147] doc: fix PullRequestRefs comment --- pkg/cmd/pr/shared/finder.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index a54528527..dc9cb8fb9 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -99,10 +99,10 @@ type FindOptions struct { // TODO: Does this also need the BaseBranchName? // PR's are represented by the following: -// baseRef -----PR-----> headRef +// headRef -----PR-----> baseRef // // A ref is described as "remoteName/branchName", so -// baseRepoName/baseBranchName -----PR-----> headRepoName/headBranchName +// headRepoName/headBranchName -----PR-----> baseRepoName/baseBranchName type PullRequestRefs struct { BranchName string HeadRepo ghrepo.Interface From a8d01c70cddb8165bfe2d654e9de602fbaf6b0cc Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 25 Feb 2025 14:42:06 -0700 Subject: [PATCH 102/147] feat: support `@{push}` revision syntax --- ...pr-create-respects-branch-pushremote.txtar | 43 ++++++ .../pr-create-respects-push-destination.txtar | 48 ++++++ ...r-create-respects-remote-pushdefault.txtar | 43 ++++++ ...r-create-respects-simple-pushdefault.txtar | 45 ++++++ pkg/cmd/pr/create/create.go | 121 ++++++--------- pkg/cmd/pr/create/create_test.go | 138 ++++-------------- 6 files changed, 257 insertions(+), 181 deletions(-) create mode 100644 acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar create mode 100644 acceptance/testdata/pr/pr-create-respects-push-destination.txtar create mode 100644 acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar create mode 100644 acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar diff --git a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar new file mode 100644 index 000000000..6f0413a68 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar @@ -0,0 +1,43 @@ +# skip 'it creates a fork owned by the user running the test' + +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} +env FORK=${REPO}-fork + +# Use gh as a credential helper +exec gh auth setup-git + +# Get the current username for the fork owner +exec gh api user --jq .login +stdout2env USER + +# Create a repository to act as upstream with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup of upstream +defer gh repo delete --yes ${ORG}/${REPO} +exec gh repo view ${ORG}/${REPO} --json id --jq '.id' +stdout2env REPO_ID + +# Create a user fork of repository. This will be owned by USER. +exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} + +# Defer repo cleanup of fork +defer gh repo delete --yes ${USER}/${FORK} +sleep 5 +exec gh repo view ${USER}/${FORK} --json id --jq '.id' +stdout2env FORK_ID + +# Clone the repo +exec gh repo clone ${USER}/${FORK} +cd ${FORK} + +# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork +exec git checkout -b feature-branch upstream/main +exec git config branch.feature-branch.pushRemote origin +exec git commit --allow-empty -m 'Empty Commit' +exec git push + +# Create the PR spanning upstream and fork repositories +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 \ No newline at end of file diff --git a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar new file mode 100644 index 000000000..6dd7e6e4f --- /dev/null +++ b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar @@ -0,0 +1,48 @@ +# skip 'it creates a fork owned by the user running the test' + +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} +env FORK=${REPO}-fork + +# Use gh as a credential helper +exec gh auth setup-git + +# Get the current username for the fork owner +exec gh api user --jq .login +stdout2env USER + +# Create a repository to act as upstream with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup of upstream +defer gh repo delete --yes ${ORG}/${REPO} +exec gh repo view ${ORG}/${REPO} --json id --jq '.id' +stdout2env REPO_ID + +# Create a user fork of repository. This will be owned by USER. +exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} + +# Defer repo cleanup of fork +defer gh repo delete --yes ${USER}/${FORK} +sleep 5 +exec gh repo view ${USER}/${FORK} --json id --jq '.id' +stdout2env FORK_ID + +# Clone the repo +exec gh repo clone ${USER}/${FORK} +cd ${FORK} + +# Configure default push behavior so local and remote branches will be the same +exec git config push.default current + +# Prepare a branch where changes are pulled from the default branch instead of remote branch of same name +exec git checkout -b feature-branch +exec git branch --set-upstream-to origin/main +exec git rev-parse --abbrev-ref feature-branch@{upstream} +stdout origin/main + +# Create the PR +exec git commit --allow-empty -m 'Empty Commit' +exec git push +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 \ No newline at end of file diff --git a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar new file mode 100644 index 000000000..cf38d3c9f --- /dev/null +++ b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar @@ -0,0 +1,43 @@ +# skip 'it creates a fork owned by the user running the test' + +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} +env FORK=${REPO}-fork + +# Use gh as a credential helper +exec gh auth setup-git + +# Get the current username for the fork owner +exec gh api user --jq .login +stdout2env USER + +# Create a repository to act as upstream with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup of upstream +defer gh repo delete --yes ${ORG}/${REPO} +exec gh repo view ${ORG}/${REPO} --json id --jq '.id' +stdout2env REPO_ID + +# Create a user fork of repository. This will be owned by USER. +exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} + +# Defer repo cleanup of fork +defer gh repo delete --yes ${USER}/${FORK} +sleep 5 +exec gh repo view ${USER}/${FORK} --json id --jq '.id' +stdout2env FORK_ID + +# Clone the repo +exec gh repo clone ${USER}/${FORK} +cd ${FORK} + +# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork +exec git checkout -b feature-branch upstream/main +exec git config remote.pushDefault origin +exec git commit --allow-empty -m 'Empty Commit' +exec git push + +# Create the PR spanning upstream and fork repositories +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 diff --git a/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar new file mode 100644 index 000000000..62d7e700e --- /dev/null +++ b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar @@ -0,0 +1,45 @@ +# skip 'it creates a fork owned by the user running the test' + +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} +env FORK=${REPO}-fork + +# Use gh as a credential helper +exec gh auth setup-git + +# Get the current username for the fork owner +exec gh api user --jq .login +stdout2env USER + +# Create a repository to act as upstream with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup of upstream +defer gh repo delete --yes ${ORG}/${REPO} +exec gh repo view ${ORG}/${REPO} --json id --jq '.id' +stdout2env REPO_ID + +# Create a user fork of repository. This will be owned by USER. +exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} + +# Defer repo cleanup of fork +defer gh repo delete --yes ${USER}/${FORK} +sleep 5 +exec gh repo view ${USER}/${FORK} --json id --jq '.id' +stdout2env FORK_ID + +# Clone the repo +exec gh repo clone ${USER}/${FORK} +cd ${FORK} + +# Configure default push behavior so local and remote branches have to be the same +exec git config push.default simple + +# Prepare a branch where changes are pulled from the default branch instead of remote branch of same name +exec git checkout -b feature-branch origin/main + +# Create the PR +exec git commit --allow-empty -m 'Empty Commit' +exec git push origin feature-branch +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 5f8979c11..56f7fa8d1 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -518,81 +518,34 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, u return nil } -// TODO: Replace with the finder's PullRequestRefs struct -// trackingRef represents a ref for a remote tracking branch. -type trackingRef struct { - remoteName string - branchName string -} - -func (r trackingRef) String() string { - return "refs/remotes/" + r.remoteName + "/" + r.branchName -} - -func mustParseTrackingRef(text string) trackingRef { - parts := strings.SplitN(string(text), "/", 4) - // The only place this is called is tryDetermineTrackingRef, where we are reconstructing - // the same tracking ref we passed in. If it doesn't match the expected format, this is a - // programmer error we want to know about, so it's ok to panic. - if len(parts) != 4 { - panic(fmt.Errorf("tracking ref should have four parts: %s", text)) - } - if parts[0] != "refs" || parts[1] != "remotes" { - panic(fmt.Errorf("tracking ref should start with refs/remotes/: %s", text)) +// isRemoteHeadCurrent returns true if the remote head is on the same sha as the local head. +// This is used to determine if we might need to push the local head branch to the remote. +func isRemoteHeadCurrent(gitClient *git.Client, prRefs shared.PullRequestRefs, remotes ghContext.Remotes) bool { + headRemote, err := remotes.FindByRepo(prRefs.HeadRepo.RepoOwner(), prRefs.HeadRepo.RepoName()) + if err != nil { + return false } - return trackingRef{ - remoteName: parts[2], - branchName: parts[3], - } -} - -// tryDetermineTrackingRef is intended to try and find a remote branch on the same commit as the currently checked out -// HEAD, i.e. the local branch. If there are multiple branches that might match, the first remote is chosen, which in -// practice is determined by the sorting algorithm applied much earlier in the process, roughly "upstream", "github", "origin", -// and then everything else unstably sorted. -func tryDetermineTrackingRef(gitClient *git.Client, remotes ghContext.Remotes, localBranchName string, headBranchConfig git.BranchConfig) (trackingRef, bool) { - // To try and determine the tracking ref for a local branch, we first construct a collection of refs - // that might be tracking, given the current branch's config, and the list of known remotes. - refsForLookup := []string{"HEAD"} - if headBranchConfig.RemoteName != "" && headBranchConfig.MergeRef != "" { - tr := trackingRef{ - remoteName: headBranchConfig.RemoteName, - branchName: strings.TrimPrefix(headBranchConfig.MergeRef, "refs/heads/"), - } - refsForLookup = append(refsForLookup, tr.String()) + refsForLookup := []string{"HEAD", fmt.Sprintf("refs/remotes/%s/%s", headRemote, prRefs.BranchName)} + resolvedRefs, err := gitClient.ShowRefs(context.Background(), refsForLookup) + if err != nil { + return false } - for _, remote := range remotes { - tr := trackingRef{ - remoteName: remote.Name, - branchName: localBranchName, - } - refsForLookup = append(refsForLookup, tr.String()) - } - - // Then we ask git for details about these refs, for example, refs/remotes/origin/trunk might return a hash - // for the remote tracking branch, trunk, for the remote, origin. If there is no ref, the git client returns - // no ref information. - // - // We also first check for the HEAD ref, so that we have the hash of the currently checked out commit. - resolvedRefs, _ := gitClient.ShowRefs(context.Background(), refsForLookup) - - // If there is more than one resolved ref, that means that at least one ref was found in addition to the HEAD. + // If there is more than one resolved ref, then remote head ref was resolved. if len(resolvedRefs) > 1 { headRef := resolvedRefs[0] for _, r := range resolvedRefs[1:] { - // If the hash of the remote ref doesn't match the hash of HEAD then the remote branch is not in the same - // state, so it can't be used. + // If the head ref is not the same as the remote head ref, then the remote head is not current. if r.Hash != headRef.Hash { continue } - // Otherwise we can parse the returned ref into a tracking ref and return that - return mustParseTrackingRef(r.Name), true + + return true } } - return trackingRef{}, false + return false } func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadataState, error) { @@ -628,6 +581,7 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata } func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { + ctx := context.Background() httpClient, err := opts.HttpClient() if err != nil { return nil, err @@ -662,6 +616,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { isPushEnabled := false headBranch := opts.HeadBranch headBranchLabel := opts.HeadBranch + if headBranch == "" { headBranch, err = opts.Branch() if err != nil { @@ -686,18 +641,38 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { return nil, err } if isPushEnabled { - // TODO: This doesn't respect the @{push} revision resolution or triagular workflows assembled with - // remote.pushDefault, or branch..pushremote config settings. The finder's ParsePRRefs - // may be able to replace this function entirely. - if trackingRef, found := tryDetermineTrackingRef(gitClient, remotes, headBranch, headBranchConfig); found { + // Suppressing these errors as we have other means of computing the PullRequestRefs when these fail. + parsedPushRevision, _ := opts.GitClient.ParsePushRevision(ctx, headBranch) + + remotePushDefault, err := opts.GitClient.RemotePushDefault(ctx) + if err != nil { + return nil, err + } + + pushDefault, err := opts.GitClient.PushDefault(ctx) + if err != nil { + return nil, err + } + + prRefs, err := shared.ParsePRRefs(headBranch, headBranchConfig, parsedPushRevision, pushDefault, remotePushDefault, baseRepo, remotes) + if err != nil { + return nil, err + } + + remoteHeadCurrent := isRemoteHeadCurrent(gitClient, prRefs, remotes) + // If the remote head is up-to-date, and we have the headRef, we do not need to push anything. + if remoteHeadCurrent && prRefs.HeadRepo != nil && prRefs.BranchName != "" { isPushEnabled = false - if r, err := remotes.FindByName(trackingRef.remoteName); err == nil { - headRepo = r - headRemote = r - headBranchLabel = trackingRef.branchName - if !ghrepo.IsSame(baseRepo, headRepo) { - headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), trackingRef.branchName) - } + headRepo = prRefs.HeadRepo + headRemote, err = remotes.FindByRepo(headRepo.RepoOwner(), headRepo.RepoName()) + // TODO: KW what does an err here mean? + if err != nil { + return nil, err + } + + headBranchLabel = prRefs.BranchName + if !ghrepo.IsSame(baseRepo, headRepo) { + headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), prRefs.BranchName) } } } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 55012d7dd..849fa4219 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -637,6 +637,9 @@ func Test_createRun(t *testing.T) { }, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") + cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") + cs.Register("git config remote.pushDefault", 0, "") + cs.Register("git config push.default", 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -700,6 +703,9 @@ func Test_createRun(t *testing.T) { }, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") + cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") + cs.Register("git config remote.pushDefault", 0, "") + cs.Register("git config push.default", 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -746,6 +752,9 @@ func Test_createRun(t *testing.T) { }, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") + cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") + cs.Register("git config remote.pushDefault", 0, "") + cs.Register("git config push.default", 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -795,6 +804,9 @@ func Test_createRun(t *testing.T) { }, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") + cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") + cs.Register("git config remote.pushDefault", 0, "") + cs.Register("git config push.default", 0, "") cs.Register("git remote rename origin upstream", 0, "") cs.Register(`git remote add origin https://github.com/monalisa/REPO.git`, 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") @@ -854,9 +866,12 @@ func Test_createRun(t *testing.T) { }, cmdStubs: func(cs *run.CommandStubber) { cs.Register("git show-ref --verify", 0, heredoc.Doc(` - deadbeef HEAD - deadb00f refs/remotes/upstream/feature - deadbeef refs/remotes/origin/feature`)) // determineTrackingBranch + deadbeef HEAD + deadb00f refs/remotes/upstream/feature + deadbeef refs/remotes/origin/feature`)) + cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") + cs.Register("git config remote.pushDefault", 0, "") + cs.Register("git config push.default", 0, "") }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for monalisa:feature into master in OWNER/REPO\n\n", @@ -894,6 +909,9 @@ func Test_createRun(t *testing.T) { deadbeef HEAD deadbeef refs/remotes/origin/my-feat2 `)) // determineTrackingBranch + cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/my-feat2") + cs.Register("git config remote.pushDefault", 0, "") + cs.Register("git config push.default", 0, "") }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for my-feat2 into master in OWNER/REPO\n\n", @@ -1075,6 +1093,9 @@ func Test_createRun(t *testing.T) { cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "") + cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") + cs.Register("git config remote.pushDefault", 0, "") + cs.Register("git config push.default", 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -1107,6 +1128,9 @@ func Test_createRun(t *testing.T) { cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "") + cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") + cs.Register("git config remote.pushDefault", 0, "") + cs.Register("git config push.default", 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -1524,6 +1548,9 @@ func Test_createRun(t *testing.T) { deadbeef HEAD deadb00f refs/remotes/upstream/feature/feat2 deadbeef refs/remotes/origin/task1`)) // determineTrackingBranch + cs.Register("git rev-parse --abbrev-ref task1@{push}", 0, "origin/task1") + cs.Register("git config remote.pushDefault", 0, "") + cs.Register("git config push.default", 0, "") }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for monalisa:task1 into feature/feat2 in OWNER/REPO\n\n", @@ -1622,111 +1649,6 @@ func Test_createRun(t *testing.T) { } } -func Test_tryDetermineTrackingRef(t *testing.T) { - tests := []struct { - name string - cmdStubs func(*run.CommandStubber) - headBranchConfig git.BranchConfig - remotes context.Remotes - expectedTrackingRef trackingRef - expectedFound bool - }{ - { - name: "empty", - cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD`, 0, "abc HEAD") - }, - headBranchConfig: git.BranchConfig{}, - expectedTrackingRef: trackingRef{}, - expectedFound: false, - }, - { - name: "no match", - cmdStubs: func(cs *run.CommandStubber) { - cs.Register("git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature", 0, "abc HEAD\nbca refs/remotes/upstream/feature") - }, - headBranchConfig: git.BranchConfig{}, - remotes: context.Remotes{ - &context.Remote{ - Remote: &git.Remote{Name: "upstream"}, - Repo: ghrepo.New("octocat", "Spoon-Knife"), - }, - &context.Remote{ - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("hubot", "Spoon-Knife"), - }, - }, - expectedTrackingRef: trackingRef{}, - expectedFound: false, - }, - { - name: "match", - cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature$`, 0, heredoc.Doc(` - deadbeef HEAD - deadb00f refs/remotes/upstream/feature - deadbeef refs/remotes/origin/feature - `)) - }, - headBranchConfig: git.BranchConfig{}, - remotes: context.Remotes{ - &context.Remote{ - Remote: &git.Remote{Name: "upstream"}, - Repo: ghrepo.New("octocat", "Spoon-Knife"), - }, - &context.Remote{ - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("hubot", "Spoon-Knife"), - }, - }, - expectedTrackingRef: trackingRef{ - remoteName: "origin", - branchName: "feature", - }, - expectedFound: true, - }, - { - name: "respect tracking config", - cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/great-feat refs/remotes/origin/feature$`, 0, heredoc.Doc(` - deadbeef HEAD - deadb00f refs/remotes/origin/feature - `)) - }, - headBranchConfig: git.BranchConfig{ - RemoteName: "origin", - MergeRef: "refs/heads/great-feat", - }, - remotes: context.Remotes{ - &context.Remote{ - Remote: &git.Remote{Name: "origin"}, - Repo: ghrepo.New("hubot", "Spoon-Knife"), - }, - }, - expectedTrackingRef: trackingRef{}, - expectedFound: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cs, cmdTeardown := run.Stub() - defer cmdTeardown(t) - - tt.cmdStubs(cs) - - gitClient := &git.Client{ - GhPath: "some/path/gh", - GitPath: "some/path/git", - } - - ref, found := tryDetermineTrackingRef(gitClient, tt.remotes, "feature", tt.headBranchConfig) - - assert.Equal(t, tt.expectedTrackingRef, ref) - assert.Equal(t, tt.expectedFound, found) - }) - } -} - func Test_generateCompareURL(t *testing.T) { tests := []struct { name string From 6db90485570c3d894aa3b25108a3a5b094ea8180 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 26 Feb 2025 16:05:50 -0700 Subject: [PATCH 103/147] refactor: Refactor pr create Refactor pr create by reducing flapping `isPushEnabled` between true and false. `isPushEnabled` is now also set to `true` by default, logically aligning with the behavior description in the help text. --- pkg/cmd/pr/create/create.go | 50 ++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 56f7fa8d1..b6d2ae748 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -613,34 +613,44 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { return nil, err } - isPushEnabled := false - headBranch := opts.HeadBranch - headBranchLabel := opts.HeadBranch - - if headBranch == "" { - headBranch, err = opts.Branch() - if err != nil { - return nil, fmt.Errorf("could not determine the current branch: %w", err) - } - headBranchLabel = headBranch - isPushEnabled = true - } else if idx := strings.IndexRune(headBranch, ':'); idx >= 0 { - headBranch = headBranch[idx+1:] - } - gitClient := opts.GitClient if ucc, err := gitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 { fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", text.Pluralize(ucc, "uncommitted change")) } + headBranch := opts.HeadBranch + headBranchLabel := opts.HeadBranch + isPushEnabled := true // Whether we will ask user where to push var headRepo ghrepo.Interface var headRemote *ghContext.Remote + var headBranchConfig git.BranchConfig + // If --head was provided, then we don't ever ask where to push. + if headBranch != "" { + isPushEnabled = false + // If the --head provided contains a colon, that means + // this is : syntax. + // TODO KW: write test for this syntax. + if idx := strings.IndexRune(headBranch, ':'); idx >= 0 { + headBranch = headBranch[idx+1:] + } + headBranchConfig, err = gitClient.ReadBranchConfig(context.Background(), headBranch) + if err != nil { + return nil, err + } + } else { + // If --head is not specified, we'll try to determine the ref + // from the current branch. If we can't, we'll prompt the user later. + headBranch, err = opts.Branch() + if err != nil { + return nil, fmt.Errorf("could not determine the current branch: %w", err) + } + headBranchLabel = headBranch + + headBranchConfig, err = gitClient.ReadBranchConfig(context.Background(), headBranch) + if err != nil { + return nil, err + } - headBranchConfig, err := gitClient.ReadBranchConfig(context.Background(), headBranch) - if err != nil { - return nil, err - } - if isPushEnabled { // Suppressing these errors as we have other means of computing the PullRequestRefs when these fail. parsedPushRevision, _ := opts.GitClient.ParsePushRevision(ctx, headBranch) From dc486258cb24ec5bb216aa317b645645160c7c08 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:23:52 -0700 Subject: [PATCH 104/147] test(pr create): test --head=: --- pkg/cmd/pr/create/create.go | 1 - pkg/cmd/pr/create/create_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index b6d2ae748..485e77b7c 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -629,7 +629,6 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { isPushEnabled = false // If the --head provided contains a colon, that means // this is : syntax. - // TODO KW: write test for this syntax. if idx := strings.IndexRune(headBranch, ':'); idx >= 0 { headBranch = headBranch[idx+1:] } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 849fa4219..a94dab48a 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1555,6 +1555,33 @@ func Test_createRun(t *testing.T) { expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for monalisa:task1 into feature/feat2 in OWNER/REPO\n\n", }, + { + name: "--head contains : syntax", + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } }`, + func(input map[string]interface{}) { + assert.Equal(t, "REPOID", input["repositoryId"]) + assert.Equal(t, "my title", input["title"]) + assert.Equal(t, "my body", input["body"]) + assert.Equal(t, "master", input["baseRefName"]) + assert.Equal(t, "otherowner:feature", input["headRefName"]) + })) + }, + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "my title" + opts.Body = "my body" + opts.HeadBranch = "otherowner:feature" + return func() {} + }, + expectedOut: "https://github.com/OWNER/REPO/pull/12\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From cd67c2efcbaba79748bda2875a1e7a43f7bef42b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:26:07 -0700 Subject: [PATCH 105/147] test(pr create): AT assert head repo and head ref --- ...pr-create-respects-branch-pushremote.txtar | 8 +++-- .../pr-create-respects-push-destination.txtar | 12 ++++--- ...r-create-respects-remote-pushdefault.txtar | 6 ++-- ...r-create-respects-simple-pushdefault.txtar | 32 ++++++------------- 4 files changed, 26 insertions(+), 32 deletions(-) diff --git a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar index 6f0413a68..aec429280 100644 --- a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar +++ b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar @@ -16,8 +16,6 @@ exec gh repo create ${ORG}/${REPO} --add-readme --private # Defer repo cleanup of upstream defer gh repo delete --yes ${ORG}/${REPO} -exec gh repo view ${ORG}/${REPO} --json id --jq '.id' -stdout2env REPO_ID # Create a user fork of repository. This will be owned by USER. exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} @@ -40,4 +38,8 @@ exec git push # Create the PR spanning upstream and fork repositories exec gh pr create --title 'Feature Title' --body 'Feature Body' -stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 \ No newline at end of file +stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 + +# Assert that the PR was created with the correct head repository and refs +exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} \ No newline at end of file diff --git a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar index 6dd7e6e4f..293850fe2 100644 --- a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar +++ b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar @@ -16,8 +16,6 @@ exec gh repo create ${ORG}/${REPO} --add-readme --private # Defer repo cleanup of upstream defer gh repo delete --yes ${ORG}/${REPO} -exec gh repo view ${ORG}/${REPO} --json id --jq '.id' -stdout2env REPO_ID # Create a user fork of repository. This will be owned by USER. exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} @@ -40,9 +38,13 @@ exec git checkout -b feature-branch exec git branch --set-upstream-to origin/main exec git rev-parse --abbrev-ref feature-branch@{upstream} stdout origin/main - -# Create the PR exec git commit --allow-empty -m 'Empty Commit' exec git push + +# Create the PR exec gh pr create --title 'Feature Title' --body 'Feature Body' -stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 \ No newline at end of file +stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 + +# Assert that the PR was created with the correct head repository and refs +exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} \ No newline at end of file diff --git a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar index cf38d3c9f..37f6ca1f2 100644 --- a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar @@ -16,8 +16,6 @@ exec gh repo create ${ORG}/${REPO} --add-readme --private # Defer repo cleanup of upstream defer gh repo delete --yes ${ORG}/${REPO} -exec gh repo view ${ORG}/${REPO} --json id --jq '.id' -stdout2env REPO_ID # Create a user fork of repository. This will be owned by USER. exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} @@ -41,3 +39,7 @@ exec git push # Create the PR spanning upstream and fork repositories exec gh pr create --title 'Feature Title' --body 'Feature Body' stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 + +# Assert that the PR was created with the correct head repository and refs +exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} \ No newline at end of file diff --git a/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar index 62d7e700e..ca420bac2 100644 --- a/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar @@ -1,45 +1,33 @@ -# skip 'it creates a fork owned by the user running the test' - # Setup environment variables used for testscript env REPO=${SCRIPT_NAME}-${RANDOM_STRING} -env FORK=${REPO}-fork # Use gh as a credential helper exec gh auth setup-git -# Get the current username for the fork owner -exec gh api user --jq .login -stdout2env USER - -# Create a repository to act as upstream with a file so it has a default branch +# Create a repository with a file so it has a default branch exec gh repo create ${ORG}/${REPO} --add-readme --private -# Defer repo cleanup of upstream +# Defer repo cleanup of repo defer gh repo delete --yes ${ORG}/${REPO} exec gh repo view ${ORG}/${REPO} --json id --jq '.id' stdout2env REPO_ID -# Create a user fork of repository. This will be owned by USER. -exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} - -# Defer repo cleanup of fork -defer gh repo delete --yes ${USER}/${FORK} -sleep 5 -exec gh repo view ${USER}/${FORK} --json id --jq '.id' -stdout2env FORK_ID - # Clone the repo -exec gh repo clone ${USER}/${FORK} -cd ${FORK} +exec gh repo clone ${ORG}/${REPO} +cd ${REPO} # Configure default push behavior so local and remote branches have to be the same exec git config push.default simple # Prepare a branch where changes are pulled from the default branch instead of remote branch of same name exec git checkout -b feature-branch origin/main - -# Create the PR exec git commit --allow-empty -m 'Empty Commit' exec git push origin feature-branch + +# Create the PR exec gh pr create --title 'Feature Title' --body 'Feature Body' stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 + +# Assert that the PR was created with the correct head repository and refs +exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${REPO_ID}","name":"${REPO}"},"isCrossRepository":false} From 84a35ca381fddf8f5265e6a355b9363a4f601982 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 27 Feb 2025 10:38:28 -0700 Subject: [PATCH 106/147] refactor: rename IsPushEnabled for clarity --- pkg/cmd/pr/create/create.go | 62 ++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 485e77b7c..c5179759f 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -75,17 +75,17 @@ type CreateOptions struct { type CreateContext struct { // This struct stores contextual data about the creation process and is for building up enough // data to create a pull request - RepoContext *ghContext.ResolvedRemotes - BaseRepo *api.Repository - HeadRepo ghrepo.Interface - BaseTrackingBranch string - BaseBranch string - HeadBranch string - HeadBranchLabel string - HeadRemote *ghContext.Remote - IsPushEnabled bool - Client *api.Client - GitClient *git.Client + RepoContext *ghContext.ResolvedRemotes + BaseRepo *api.Repository + HeadRepo ghrepo.Interface + BaseTrackingBranch string + BaseBranch string + HeadBranch string + HeadBranchLabel string + HeadRemote *ghContext.Remote + PromptForPushDestination bool + Client *api.Client + GitClient *git.Client } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -620,13 +620,13 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { headBranch := opts.HeadBranch headBranchLabel := opts.HeadBranch - isPushEnabled := true // Whether we will ask user where to push + promptForPushDestination := true // Whether we will prompt the user for where to push the branch. var headRepo ghrepo.Interface var headRemote *ghContext.Remote var headBranchConfig git.BranchConfig // If --head was provided, then we don't ever ask where to push. if headBranch != "" { - isPushEnabled = false + promptForPushDestination = false // If the --head provided contains a colon, that means // this is : syntax. if idx := strings.IndexRune(headBranch, ':'); idx >= 0 { @@ -671,7 +671,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { remoteHeadCurrent := isRemoteHeadCurrent(gitClient, prRefs, remotes) // If the remote head is up-to-date, and we have the headRef, we do not need to push anything. if remoteHeadCurrent && prRefs.HeadRepo != nil && prRefs.BranchName != "" { - isPushEnabled = false + promptForPushDestination = false headRepo = prRefs.HeadRepo headRemote, err = remotes.FindByRepo(headRepo.RepoOwner(), headRepo.RepoName()) // TODO: KW what does an err here mean? @@ -687,7 +687,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { } // otherwise, ask the user for the head repository using info obtained from the API - if headRepo == nil && isPushEnabled && opts.IO.CanPrompt() { + if headRepo == nil && promptForPushDestination && opts.IO.CanPrompt() { pushableRepos, err := repoContext.HeadRepos() if err != nil { return nil, err @@ -731,7 +731,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch) } } else if pushOptions[selectedOption] == "Skip pushing the branch" { - isPushEnabled = false + promptForPushDestination = false } else if pushOptions[selectedOption] == "Cancel" { return nil, cmdutil.CancelError } else { @@ -740,7 +740,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { } } - if headRepo == nil && isPushEnabled && !opts.IO.CanPrompt() { + if headRepo == nil && promptForPushDestination && !opts.IO.CanPrompt() { fmt.Fprintf(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag") return nil, cmdutil.SilentError } @@ -762,17 +762,17 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { } return &CreateContext{ - BaseRepo: baseRepo, - HeadRepo: headRepo, - BaseBranch: baseBranch, - BaseTrackingBranch: baseTrackingBranch, - HeadBranch: headBranch, - HeadBranchLabel: headBranchLabel, - HeadRemote: headRemote, - IsPushEnabled: isPushEnabled, - RepoContext: repoContext, - Client: client, - GitClient: gitClient, + BaseRepo: baseRepo, + HeadRepo: headRepo, + BaseBranch: baseBranch, + BaseTrackingBranch: baseTrackingBranch, + HeadBranch: headBranch, + HeadBranchLabel: headBranchLabel, + HeadRemote: headRemote, + PromptForPushDestination: promptForPushDestination, + RepoContext: repoContext, + Client: client, + GitClient: gitClient, }, nil } @@ -924,7 +924,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { var err error // if a head repository could not be determined so far, automatically create // one by forking the base repository - if headRepo == nil && ctx.IsPushEnabled { + if headRepo == nil && ctx.PromptForPushDestination { opts.IO.StartProgressIndicator() headRepo, err = api.ForkRepo(client, ctx.BaseRepo, "", "", false) opts.IO.StopProgressIndicator() @@ -946,7 +946,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { // can push to it. We will try to add the head repo as the "origin" remote // and fallback to the "fork" remote if it is unavailable. Also, if the // base repo is the "origin" remote we will rename it "upstream". - if headRemote == nil && ctx.IsPushEnabled { + if headRemote == nil && ctx.PromptForPushDestination { cfg, err := opts.Config() if err != nil { return err @@ -1008,7 +1008,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { } // automatically push the branch if it hasn't been pushed anywhere yet - if ctx.IsPushEnabled { + if ctx.PromptForPushDestination { pushBranch := func() error { w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "") defer w.Flush() From 1848beb94b8c74921fbeef0794fa1a1c3882fa36 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 28 Feb 2025 09:00:50 -0700 Subject: [PATCH 107/147] test(pr create): unset gh-resolved --- .../testdata/pr/pr-create-respects-branch-pushremote.txtar | 1 + acceptance/testdata/pr/pr-create-respects-push-destination.txtar | 1 + .../testdata/pr/pr-create-respects-remote-pushdefault.txtar | 1 + 3 files changed, 3 insertions(+) diff --git a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar index aec429280..0acdbc4fb 100644 --- a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar +++ b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar @@ -33,6 +33,7 @@ cd ${FORK} # Prepare a branch where changes are pulled from the upstream default branch but pushed to fork exec git checkout -b feature-branch upstream/main exec git config branch.feature-branch.pushRemote origin +exec git config unset remote.upstream.gh-resolved exec git commit --allow-empty -m 'Empty Commit' exec git push diff --git a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar index 293850fe2..85b194634 100644 --- a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar +++ b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar @@ -38,6 +38,7 @@ exec git checkout -b feature-branch exec git branch --set-upstream-to origin/main exec git rev-parse --abbrev-ref feature-branch@{upstream} stdout origin/main +exec git config unset remote.upstream.gh-resolved exec git commit --allow-empty -m 'Empty Commit' exec git push diff --git a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar index 37f6ca1f2..660214071 100644 --- a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar @@ -33,6 +33,7 @@ cd ${FORK} # Prepare a branch where changes are pulled from the upstream default branch but pushed to fork exec git checkout -b feature-branch upstream/main exec git config remote.pushDefault origin +exec git config unset remote.upstream.gh-resolved exec git commit --allow-empty -m 'Empty Commit' exec git push From 10e39493488e889c1d0360e72e25f773b1a943ec Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 28 Feb 2025 09:14:39 -0700 Subject: [PATCH 108/147] refactor(pr create): use prRefs.GetPRHeadLabel() --- pkg/cmd/pr/create/create.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index c5179759f..0b4a1ca8f 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -679,10 +679,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { return nil, err } - headBranchLabel = prRefs.BranchName - if !ghrepo.IsSame(baseRepo, headRepo) { - headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), prRefs.BranchName) - } + headBranchLabel = prRefs.GetPRHeadLabel() } } From a5fe37f91b7621e7f52a4e975dec513862377193 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 28 Feb 2025 09:56:41 -0700 Subject: [PATCH 109/147] test(pr create): add AT for remote:branch syntax --- ...-respects-remote-colon-branch-syntax.txtar | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar diff --git a/acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar b/acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar new file mode 100644 index 000000000..fe2988699 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar @@ -0,0 +1,46 @@ +# skip 'it creates a fork owned by the user running the test' + +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} +env FORK=${REPO}-fork + +# Use gh as a credential helper +exec gh auth setup-git + +# Get the current username for the fork owner +exec gh api user --jq .login +stdout2env USER + +# Create a repository to act as upstream with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup of upstream +defer gh repo delete --yes ${ORG}/${REPO} + +# Create a user fork of repository. This will be owned by USER. +exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} + +# Defer repo cleanup of fork +defer gh repo delete --yes ${USER}/${FORK} +sleep 5 +exec gh repo view ${USER}/${FORK} --json id --jq '.id' +stdout2env FORK_ID + +# Clone the repo +exec gh repo clone ${USER}/${FORK} +cd ${FORK} + +# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork +exec git checkout -b feature-branch upstream/main +exec git config remote.pushDefault origin +exec git config unset remote.upstream.gh-resolved +exec git commit --allow-empty -m 'Empty Commit' +exec git push + +# Create the PR spanning upstream and fork repositories +exec gh pr create --title 'Feature Title' --body 'Feature Body' --head ${USER}:feature-branch +stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 + +# Assert that the PR was created with the correct head repository and refs +exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} \ No newline at end of file From c3087cde991bce40b29e5133201507f167159115 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 5 Mar 2025 10:51:34 -0700 Subject: [PATCH 110/147] refactor(pr create): Refactor NewCreateContext - Use prRefs instead of local vars more. - Rename variables for readability. - Improve comments. - Refactor tests. --- pkg/cmd/pr/create/create.go | 240 ++++++++++++++++--------------- pkg/cmd/pr/create/create_test.go | 77 +++++----- 2 files changed, 160 insertions(+), 157 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 0b4a1ca8f..e1e5186cf 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -75,17 +75,17 @@ type CreateOptions struct { type CreateContext struct { // This struct stores contextual data about the creation process and is for building up enough // data to create a pull request - RepoContext *ghContext.ResolvedRemotes - BaseRepo *api.Repository - HeadRepo ghrepo.Interface - BaseTrackingBranch string - BaseBranch string - HeadBranch string - HeadBranchLabel string - HeadRemote *ghContext.Remote - PromptForPushDestination bool - Client *api.Client - GitClient *git.Client + RepoContext *ghContext.ResolvedRemotes + BaseRepo *api.Repository + HeadRepo ghrepo.Interface + BaseTrackingBranch string + BaseBranch string + HeadBranch string + HeadBranchLabel string + HeadRemote *ghContext.Remote + isPushEnabled bool + Client *api.Client + GitClient *git.Client } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -592,112 +592,115 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { if err != nil { return nil, err } - repoContext, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) - if err != nil { - return nil, err - } - - var baseRepo *api.Repository - if br, err := repoContext.BaseRepo(opts.IO); err == nil { - if r, ok := br.(*api.Repository); ok { - baseRepo = r - } else { - // TODO: if RepoNetwork is going to be requested anyway in `repoContext.HeadRepos()`, - // consider piggybacking on that result instead of performing a separate lookup - baseRepo, err = api.GitHubRepo(client, br) - if err != nil { - return nil, err - } - } - } else { - return nil, err - } gitClient := opts.GitClient if ucc, err := gitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 { fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", text.Pluralize(ucc, "uncommitted change")) } - headBranch := opts.HeadBranch - headBranchLabel := opts.HeadBranch - promptForPushDestination := true // Whether we will prompt the user for where to push the branch. - var headRepo ghrepo.Interface - var headRemote *ghContext.Remote - var headBranchConfig git.BranchConfig - // If --head was provided, then we don't ever ask where to push. - if headBranch != "" { - promptForPushDestination = false - // If the --head provided contains a colon, that means - // this is : syntax. - if idx := strings.IndexRune(headBranch, ':'); idx >= 0 { - headBranch = headBranch[idx+1:] - } - headBranchConfig, err = gitClient.ReadBranchConfig(context.Background(), headBranch) - if err != nil { - return nil, err - } - } else { - // If --head is not specified, we'll try to determine the ref - // from the current branch. If we can't, we'll prompt the user later. - headBranch, err = opts.Branch() - if err != nil { - return nil, fmt.Errorf("could not determine the current branch: %w", err) - } - headBranchLabel = headBranch + // Resolve base repo + repoContext, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) + if err != nil { + return nil, err + } - headBranchConfig, err = gitClient.ReadBranchConfig(context.Background(), headBranch) - if err != nil { - return nil, err - } - - // Suppressing these errors as we have other means of computing the PullRequestRefs when these fail. - parsedPushRevision, _ := opts.GitClient.ParsePushRevision(ctx, headBranch) - - remotePushDefault, err := opts.GitClient.RemotePushDefault(ctx) - if err != nil { - return nil, err - } - - pushDefault, err := opts.GitClient.PushDefault(ctx) - if err != nil { - return nil, err - } - - prRefs, err := shared.ParsePRRefs(headBranch, headBranchConfig, parsedPushRevision, pushDefault, remotePushDefault, baseRepo, remotes) - if err != nil { - return nil, err - } - - remoteHeadCurrent := isRemoteHeadCurrent(gitClient, prRefs, remotes) - // If the remote head is up-to-date, and we have the headRef, we do not need to push anything. - if remoteHeadCurrent && prRefs.HeadRepo != nil && prRefs.BranchName != "" { - promptForPushDestination = false - headRepo = prRefs.HeadRepo - headRemote, err = remotes.FindByRepo(headRepo.RepoOwner(), headRepo.RepoName()) - // TODO: KW what does an err here mean? + var targetBaseRepo *api.Repository + if br, err := repoContext.BaseRepo(opts.IO); err == nil { + if r, ok := br.(*api.Repository); ok { + targetBaseRepo = r + } else { + // TODO: if RepoNetwork is going to be requested anyway in `repoContext.HeadRepos()`, + // consider piggybacking on that result instead of performing a separate lookup + targetBaseRepo, err = api.GitHubRepo(client, br) if err != nil { return nil, err } + } + } else { + return nil, err + } - headBranchLabel = prRefs.GetPRHeadLabel() + // Resolve target head branch name from either + // --head or the current branch. + var targetHeadBranch string + var headBranchLabel string + isPushEnabled := true + + if opts.HeadBranch != "" { + isPushEnabled = false + targetHeadBranch = opts.HeadBranch + headBranchLabel = opts.HeadBranch + // If the --head provided contains a colon, that means + // this is : syntax. + if idx := strings.IndexRune(targetHeadBranch, ':'); idx >= 0 { + targetHeadBranch = targetHeadBranch[idx+1:] + } + } else { + // Use the current branch as the target local head branch when + // --head is not provided. + targetHeadBranch, err = opts.Branch() + headBranchLabel = targetHeadBranch + if err != nil { + return nil, fmt.Errorf("could not determine the current branch: %w", err) } } - // otherwise, ask the user for the head repository using info obtained from the API - if headRepo == nil && promptForPushDestination && opts.IO.CanPrompt() { + targetHeadBranchConfig, err := gitClient.ReadBranchConfig(context.Background(), targetHeadBranch) + if err != nil { + return nil, err + } + + // See if we can determine if this branch has been push previously with + // Git configurations and @{push} revision syntax. + remotePushDefault, err := opts.GitClient.RemotePushDefault(ctx) + if err != nil { + return nil, err + } + // Suppressing these errors as we have other means of computing the PullRequestRefs when these fail. + parsedPushRevision, _ := opts.GitClient.ParsePushRevision(ctx, targetHeadBranch) + pushDefault, err := opts.GitClient.PushDefault(ctx) + if err != nil { + return nil, err + } + + prRefs, err := shared.ParsePRRefs(targetHeadBranch, targetHeadBranchConfig, parsedPushRevision, pushDefault, remotePushDefault, targetBaseRepo, remotes) + if err != nil { + return nil, err + } + + // We received the head repository and branch from ParsePRRefs, but we + // need to check if it's up-to-date with our local branch state. + // If it is, we can use it as the head remote for the PR + // and avoid prompting the user. + var headRemote *ghContext.Remote + + remoteHeadCurrent := isRemoteHeadCurrent(gitClient, prRefs, remotes) + if remoteHeadCurrent && prRefs.HeadRepo != nil && prRefs.BranchName != "" { + isPushEnabled = false + headRemote, err = remotes.FindByRepo(prRefs.HeadRepo.RepoOwner(), prRefs.HeadRepo.RepoName()) + // TODO: KW what does an err here mean? + // If we fail to find a remote for that repo, shouldn't we just try to prompt + // for head repos? + if err != nil { + return nil, err + } + headBranchLabel = prRefs.GetPRHeadLabel() + } else if isPushEnabled && opts.IO.CanPrompt() { + // Since we could not determine a head ref, prompt the user for the head repository to push + // using a list of repositories obtained from the API pushableRepos, err := repoContext.HeadRepos() if err != nil { return nil, err } if len(pushableRepos) == 0 { - pushableRepos, err = api.RepoFindForks(client, baseRepo, 3) + pushableRepos, err = api.RepoFindForks(client, prRefs.BaseRepo, 3) if err != nil { return nil, err } } - currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost()) + currentLogin, err := api.CurrentLoginName(client, prRefs.BaseRepo.RepoHost()) if err != nil { return nil, err } @@ -712,64 +715,65 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { } if !hasOwnFork { - pushOptions = append(pushOptions, "Create a fork of "+ghrepo.FullName(baseRepo)) + pushOptions = append(pushOptions, "Create a fork of "+ghrepo.FullName(prRefs.BaseRepo)) } pushOptions = append(pushOptions, "Skip pushing the branch") pushOptions = append(pushOptions, "Cancel") - selectedOption, err := opts.Prompter.Select(fmt.Sprintf("Where should we push the '%s' branch?", headBranch), "", pushOptions) + selectedOption, err := opts.Prompter.Select(fmt.Sprintf("Where should we push the '%s' branch?", prRefs.BranchName), "", pushOptions) if err != nil { return nil, err } if selectedOption < len(pushableRepos) { - headRepo = pushableRepos[selectedOption] - if !ghrepo.IsSame(baseRepo, headRepo) { - headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch) + prRefs.HeadRepo = pushableRepos[selectedOption] + if !ghrepo.IsSame(prRefs.BaseRepo, prRefs.HeadRepo) { + headBranchLabel = fmt.Sprintf("%s:%s", prRefs.HeadRepo.RepoOwner(), prRefs.BranchName) } } else if pushOptions[selectedOption] == "Skip pushing the branch" { - promptForPushDestination = false + isPushEnabled = false } else if pushOptions[selectedOption] == "Cancel" { return nil, cmdutil.CancelError } else { // "Create a fork of ..." - headBranchLabel = fmt.Sprintf("%s:%s", currentLogin, headBranch) + headBranchLabel = fmt.Sprintf("%s:%s", currentLogin, prRefs.BranchName) + prRefs.HeadRepo = nil } } - if headRepo == nil && promptForPushDestination && !opts.IO.CanPrompt() { + if prRefs.HeadRepo == nil && isPushEnabled && !opts.IO.CanPrompt() { fmt.Fprintf(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag") return nil, cmdutil.SilentError } baseBranch := opts.BaseBranch if baseBranch == "" { - baseBranch = headBranchConfig.MergeBase + baseBranch = targetHeadBranchConfig.MergeBase } if baseBranch == "" { - baseBranch = baseRepo.DefaultBranchRef.Name + baseBranch = targetBaseRepo.DefaultBranchRef.Name } - if headBranch == baseBranch && headRepo != nil && ghrepo.IsSame(baseRepo, headRepo) { + if prRefs.BranchName == baseBranch && prRefs.HeadRepo != nil && ghrepo.IsSame(prRefs.BaseRepo, prRefs.HeadRepo) { return nil, fmt.Errorf("must be on a branch named differently than %q", baseBranch) } baseTrackingBranch := baseBranch - if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil { + if baseRemote, err := remotes.FindByRepo(prRefs.BaseRepo.RepoOwner(), prRefs.BaseRepo.RepoName()); err == nil { baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch) } return &CreateContext{ - BaseRepo: baseRepo, - HeadRepo: headRepo, - BaseBranch: baseBranch, - BaseTrackingBranch: baseTrackingBranch, - HeadBranch: headBranch, - HeadBranchLabel: headBranchLabel, - HeadRemote: headRemote, - PromptForPushDestination: promptForPushDestination, - RepoContext: repoContext, - Client: client, - GitClient: gitClient, + BaseRepo: prRefs.BaseRepo.(*api.Repository), + HeadRepo: prRefs.HeadRepo, + BaseBranch: baseBranch, // Currently not supported by shared.PullRequestRefs struct + BaseTrackingBranch: baseTrackingBranch, + HeadBranch: prRefs.BranchName, + HeadBranchLabel: headBranchLabel, + HeadRemote: headRemote, + isPushEnabled: isPushEnabled, + RepoContext: repoContext, + Client: client, + GitClient: gitClient, }, nil } @@ -921,7 +925,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { var err error // if a head repository could not be determined so far, automatically create // one by forking the base repository - if headRepo == nil && ctx.PromptForPushDestination { + if headRepo == nil && ctx.isPushEnabled { opts.IO.StartProgressIndicator() headRepo, err = api.ForkRepo(client, ctx.BaseRepo, "", "", false) opts.IO.StopProgressIndicator() @@ -943,7 +947,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { // can push to it. We will try to add the head repo as the "origin" remote // and fallback to the "fork" remote if it is unavailable. Also, if the // base repo is the "origin" remote we will rename it "upstream". - if headRemote == nil && ctx.PromptForPushDestination { + if headRemote == nil && ctx.isPushEnabled { cfg, err := opts.Config() if err != nil { return err @@ -1005,7 +1009,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { } // automatically push the branch if it hasn't been pushed anywhere yet - if ctx.PromptForPushDestination { + if ctx.isPushEnabled { pushBranch := func() error { w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "") defer w.Flush() diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index a94dab48a..8353e8c4b 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -332,18 +332,19 @@ func TestNewCmdCreate(t *testing.T) { func Test_createRun(t *testing.T) { tests := []struct { - name string - setup func(*CreateOptions, *testing.T) func() - cmdStubs func(*run.CommandStubber) - promptStubs func(*prompter.PrompterMock) - httpStubs func(*httpmock.Registry, *testing.T) - expectedOutputs []string - expectedOut string - expectedErrOut string - expectedBrowse string - wantErr string - tty bool - customBranchConfig bool + name string + setup func(*CreateOptions, *testing.T) func() + cmdStubs func(*run.CommandStubber) + promptStubs func(*prompter.PrompterMock) + httpStubs func(*httpmock.Registry, *testing.T) + expectedOutputs []string + expectedOut string + expectedErrOut string + expectedBrowse string + wantErr string + tty bool + customBranchConfig bool + customPushDestination bool }{ { name: "nontty web", @@ -636,10 +637,6 @@ func Test_createRun(t *testing.T) { })) }, cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") - cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") - cs.Register("git config remote.pushDefault", 0, "") - cs.Register("git config push.default", 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -702,10 +699,6 @@ func Test_createRun(t *testing.T) { })) }, cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") - cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") - cs.Register("git config remote.pushDefault", 0, "") - cs.Register("git config push.default", 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -751,10 +744,6 @@ func Test_createRun(t *testing.T) { })) }, cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") - cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") - cs.Register("git config remote.pushDefault", 0, "") - cs.Register("git config push.default", 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -802,9 +791,10 @@ func Test_createRun(t *testing.T) { assert.Equal(t, "monalisa:feature", input["headRefName"].(string)) })) }, + customPushDestination: true, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") - cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") + cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "") cs.Register("git config remote.pushDefault", 0, "") cs.Register("git config push.default", 0, "") cs.Register("git remote rename origin upstream", 0, "") @@ -864,6 +854,7 @@ func Test_createRun(t *testing.T) { assert.Equal(t, "monalisa:feature", input["headRefName"].(string)) })) }, + customPushDestination: true, cmdStubs: func(cs *run.CommandStubber) { cs.Register("git show-ref --verify", 0, heredoc.Doc(` deadbeef HEAD @@ -899,7 +890,8 @@ func Test_createRun(t *testing.T) { assert.Equal(t, "my-feat2", input["headRefName"].(string)) })) }, - customBranchConfig: true, + customBranchConfig: true, + customPushDestination: true, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git config --get-regexp \^branch\\\.feature\\\.`, 0, heredoc.Doc(` branch.feature.remote origin @@ -1091,11 +1083,7 @@ func Test_createRun(t *testing.T) { httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) }, cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "") - cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") - cs.Register("git config remote.pushDefault", 0, "") - cs.Register("git config push.default", 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -1126,11 +1114,7 @@ func Test_createRun(t *testing.T) { mockRetrieveProjects(t, reg) }, cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "") - cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") - cs.Register("git config remote.pushDefault", 0, "") - cs.Register("git config push.default", 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -1313,11 +1297,17 @@ func Test_createRun(t *testing.T) { reg.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.StringResponse(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } } `)) }, + customPushDestination: true, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "") + cs.Register("git config remote.pushDefault", 0, "") + cs.Register("git config push.default", 0, "") + }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", }, { @@ -1538,7 +1528,8 @@ func Test_createRun(t *testing.T) { assert.Equal(t, "monalisa:task1", input["headRefName"].(string)) })) }, - customBranchConfig: true, + customBranchConfig: true, + customPushDestination: true, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git config --get-regexp \^branch\\\.task1\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, heredoc.Doc(` branch.task1.remote origin @@ -1586,7 +1577,6 @@ func Test_createRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { branch := "feature" - reg := &httpmock.Registry{} reg.StubRepoInfoResponse("OWNER", "REPO", "master") defer reg.Verify(t) @@ -1603,6 +1593,15 @@ func Test_createRun(t *testing.T) { cs, cmdTeardown := run.Stub() defer cmdTeardown(t) cs.Register(`git status --porcelain`, 0, "") + // TODO this could be be values in the test struct with a helper + // function to invoke the apporpriate command stubs based on + // those values. + if !tt.customPushDestination { + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") + cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") + cs.Register("git config remote.pushDefault", 0, "") + cs.Register("git config push.default", 0, "") + } if !tt.customBranchConfig { cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") } From 178fb405159431a5df38f5b986006b3b1f644084 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 5 Mar 2025 11:06:01 -0700 Subject: [PATCH 111/147] refactor(pr create): Use PrRefs in CreateContext Replace BaseRepo, HeadRepo, HeadBranch with PrRefs in CreateContext struct. --- pkg/cmd/pr/create/create.go | 42 +++++++++++++++----------------- pkg/cmd/pr/create/create_test.go | 20 +++++++++++---- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index e1e5186cf..2ba3d2d10 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -76,11 +76,9 @@ type CreateContext struct { // This struct stores contextual data about the creation process and is for building up enough // data to create a pull request RepoContext *ghContext.ResolvedRemotes - BaseRepo *api.Repository - HeadRepo ghrepo.Interface + PrRefs shared.PullRequestRefs BaseTrackingBranch string - BaseBranch string - HeadBranch string + BaseBranch string // Currently not supported by shared.PullRequestRefs struct HeadBranchLabel string HeadRemote *ghContext.Remote isPushEnabled bool @@ -338,7 +336,7 @@ func createRun(opts *CreateOptions) error { fmt.Fprintf(opts.IO.ErrOut, message, cs.Cyan(ctx.HeadBranchLabel), cs.Cyan(ctx.BaseBranch), - ghrepo.FullName(ctx.BaseRepo)) + ghrepo.FullName(ctx.PrRefs.BaseRepo)) } if !opts.EditorMode && (opts.FillVerbose || opts.Autofill || opts.FillFirst || (opts.TitleProvided && opts.BodyProvided)) { @@ -361,7 +359,7 @@ func createRun(opts *CreateOptions) error { action = shared.SubmitDraftAction } - tpl := shared.NewTemplateManager(client.HTTP(), ctx.BaseRepo, opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true) + tpl := shared.NewTemplateManager(client.HTTP(), ctx.PrRefs.BaseRepo, opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true) if opts.EditorMode { if opts.Template != "" { @@ -429,7 +427,7 @@ func createRun(opts *CreateOptions) error { } allowPreview := !state.HasMetadata() && shared.ValidURL(openURL) && !opts.DryRun - allowMetadata := ctx.BaseRepo.ViewerCanTriage() + allowMetadata := ctx.PrRefs.BaseRepo.(*api.Repository).ViewerCanTriage() action, err = shared.ConfirmPRSubmission(opts.Prompter, allowPreview, allowMetadata, state.Draft) if err != nil { return fmt.Errorf("unable to confirm: %w", err) @@ -439,10 +437,10 @@ func createRun(opts *CreateOptions) error { fetcher := &shared.MetadataFetcher{ IO: opts.IO, APIClient: client, - Repo: ctx.BaseRepo, + Repo: ctx.PrRefs.BaseRepo, State: state, } - err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state) + err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PrRefs.BaseRepo, fetcher, state) if err != nil { return err } @@ -486,7 +484,7 @@ var regexPattern = regexp.MustCompile(`(?m)^`) func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, useFirstCommit bool, addBody bool) error { baseRef := ctx.BaseTrackingBranch - headRef := ctx.HeadBranch + headRef := ctx.PrRefs.BranchName gitClient := ctx.GitClient commits, err := gitClient.Commits(context.Background(), baseRef, headRef) @@ -554,7 +552,7 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata milestoneTitles = []string{opts.Milestone} } - meReplacer := shared.NewMeReplacer(ctx.Client, ctx.BaseRepo.RepoHost()) + meReplacer := shared.NewMeReplacer(ctx.Client, ctx.PrRefs.BaseRepo.RepoHost()) assignees, err := meReplacer.ReplaceSlice(opts.Assignees) if err != nil { return nil, err @@ -763,11 +761,9 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { } return &CreateContext{ - BaseRepo: prRefs.BaseRepo.(*api.Repository), - HeadRepo: prRefs.HeadRepo, + PrRefs: prRefs, BaseBranch: baseBranch, // Currently not supported by shared.PullRequestRefs struct BaseTrackingBranch: baseTrackingBranch, - HeadBranch: prRefs.BranchName, HeadBranchLabel: headBranchLabel, HeadRemote: headRemote, isPushEnabled: isPushEnabled, @@ -806,7 +802,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS return errors.New("pull request title must not be blank") } - err := shared.AddMetadataToIssueParams(client, ctx.BaseRepo, params, &state) + err := shared.AddMetadataToIssueParams(client, ctx.PrRefs.BaseRepo, params, &state) if err != nil { return err } @@ -820,7 +816,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS } opts.IO.StartProgressIndicator() - pr, err := api.CreatePullRequest(client, ctx.BaseRepo, params) + pr, err := api.CreatePullRequest(client, ctx.PrRefs.BaseRepo.(*api.Repository), params) opts.IO.StopProgressIndicator() if pr != nil { fmt.Fprintln(opts.IO.Out, pr.URL) @@ -917,7 +913,7 @@ func previewPR(opts CreateOptions, openURL string) error { func handlePush(opts CreateOptions, ctx CreateContext) error { didForkRepo := false - headRepo := ctx.HeadRepo + headRepo := ctx.PrRefs.HeadRepo headRemote := ctx.HeadRemote client := ctx.Client gitClient := ctx.GitClient @@ -927,7 +923,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { // one by forking the base repository if headRepo == nil && ctx.isPushEnabled { opts.IO.StartProgressIndicator() - headRepo, err = api.ForkRepo(client, ctx.BaseRepo, "", "", false) + headRepo, err = api.ForkRepo(client, ctx.PrRefs.BaseRepo, "", "", false) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("error forking repo: %w", err) @@ -970,7 +966,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { remoteName = "fork" } - if origin != nil && upstream == nil && ghrepo.IsSame(origin, ctx.BaseRepo) { + if origin != nil && upstream == nil && ghrepo.IsSame(origin, ctx.PrRefs.BaseRepo) { renameCmd, err := gitClient.Command(context.Background(), "remote", "rename", "origin", upstreamName) if err != nil { return err @@ -979,7 +975,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { return fmt.Errorf("error renaming origin remote: %w", err) } remoteName = "origin" - fmt.Fprintf(opts.IO.ErrOut, "Changed %s remote to %q\n", ghrepo.FullName(ctx.BaseRepo), upstreamName) + fmt.Fprintf(opts.IO.ErrOut, "Changed %s remote to %q\n", ghrepo.FullName(ctx.PrRefs.BaseRepo), upstreamName) } gitRemote, err := gitClient.AddRemote(context.Background(), remoteName, headRepoURL, []string{}) @@ -1013,7 +1009,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { pushBranch := func() error { w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "") defer w.Flush() - ref := fmt.Sprintf("HEAD:refs/heads/%s", ctx.HeadBranch) + ref := fmt.Sprintf("HEAD:refs/heads/%s", ctx.PrRefs.BranchName) bo := backoff.NewConstantBackOff(2 * time.Second) ctx := context.Background() return backoff.Retry(func() error { @@ -1040,10 +1036,10 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (string, error) { u := ghrepo.GenerateRepoURL( - ctx.BaseRepo, + ctx.PrRefs.BaseRepo, "compare/%s...%s?expand=1", url.PathEscape(ctx.BaseBranch), url.PathEscape(ctx.HeadBranchLabel)) - url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.BaseRepo, u, state) + url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PrRefs.BaseRepo, u, state) if err != nil { return "", err } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 8353e8c4b..a69ab3b3a 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1686,7 +1686,9 @@ func Test_generateCompareURL(t *testing.T) { { name: "basic", ctx: CreateContext{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + PrRefs: shared.PullRequestRefs{ + BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + }, BaseBranch: "main", HeadBranchLabel: "feature", }, @@ -1696,7 +1698,9 @@ func Test_generateCompareURL(t *testing.T) { { name: "with labels", ctx: CreateContext{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + PrRefs: shared.PullRequestRefs{ + BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + }, BaseBranch: "a", HeadBranchLabel: "b", }, @@ -1709,7 +1713,9 @@ func Test_generateCompareURL(t *testing.T) { { name: "'/'s in branch names/labels are percent-encoded", ctx: CreateContext{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + PrRefs: shared.PullRequestRefs{ + BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + }, BaseBranch: "main/trunk", HeadBranchLabel: "owner:feature", }, @@ -1725,7 +1731,9 @@ func Test_generateCompareURL(t *testing.T) { - See https://github.com/golang/go/issues/27559. */ ctx: CreateContext{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + PrRefs: shared.PullRequestRefs{ + BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + }, BaseBranch: "main/trunk", HeadBranchLabel: "owner:!$&'()+,;=@", }, @@ -1735,7 +1743,9 @@ func Test_generateCompareURL(t *testing.T) { { name: "with template", ctx: CreateContext{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + PrRefs: shared.PullRequestRefs{ + BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + }, BaseBranch: "main", HeadBranchLabel: "feature", }, From c0c5d9123deefc45814daa716ab25e92c70e2f63 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 5 Mar 2025 14:02:57 -0700 Subject: [PATCH 112/147] refactor(pr create): use GetPRHeadLabel() Use PrRefs.GetPRHeadLabel() instead of headBranchLabel. Also remove headBranchLabel from CreateContext struct. To do this, we needed a new identifier for when the head repo should be created via a new fork of the base repo. Previously, this was done by checking if the head repo was nil, but if we want to call GetPRHeadLabel(), it requires a non-nil head repo to construct the headBranchLabel. So, instead of the head repo being nil to signal a fork, we pass a new forkHeadRepo bool in the CreateContext struct. This also makes the decision to fork more intentional; now the decision is made clearly instead of if the headRepo happens to be nil. --- pkg/cmd/pr/create/create.go | 46 +++++++++++++++++-------------- pkg/cmd/pr/create/create_test.go | 47 ++++++++++++++++++++------------ 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 2ba3d2d10..371b14dbe 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -79,9 +79,9 @@ type CreateContext struct { PrRefs shared.PullRequestRefs BaseTrackingBranch string BaseBranch string // Currently not supported by shared.PullRequestRefs struct - HeadBranchLabel string HeadRemote *ghContext.Remote isPushEnabled bool + forkHeadRepo bool Client *api.Client GitClient *git.Client } @@ -308,7 +308,7 @@ func createRun(opts *CreateOptions) error { } existingPR, _, err := opts.Finder.Find(shared.FindOptions{ - Selector: ctx.HeadBranchLabel, + Selector: ctx.PrRefs.GetPRHeadLabel(), BaseBranch: ctx.BaseBranch, States: []string{"OPEN"}, Fields: []string{"url"}, @@ -319,7 +319,7 @@ func createRun(opts *CreateOptions) error { } if err == nil { return fmt.Errorf("a pull request for branch %q into branch %q already exists:\n%s", - ctx.HeadBranchLabel, ctx.BaseBranch, existingPR.URL) + ctx.PrRefs.GetPRHeadLabel(), ctx.BaseBranch, existingPR.URL) } message := "\nCreating pull request for %s into %s in %s\n\n" @@ -334,7 +334,7 @@ func createRun(opts *CreateOptions) error { if opts.IO.CanPrompt() { fmt.Fprintf(opts.IO.ErrOut, message, - cs.Cyan(ctx.HeadBranchLabel), + cs.Cyan(ctx.PrRefs.GetPRHeadLabel()), cs.Cyan(ctx.BaseBranch), ghrepo.FullName(ctx.PrRefs.BaseRepo)) } @@ -621,23 +621,23 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { // Resolve target head branch name from either // --head or the current branch. var targetHeadBranch string - var headBranchLabel string + var targetHeadRepoOwner string + isPushEnabled := true if opts.HeadBranch != "" { isPushEnabled = false targetHeadBranch = opts.HeadBranch - headBranchLabel = opts.HeadBranch // If the --head provided contains a colon, that means // this is : syntax. - if idx := strings.IndexRune(targetHeadBranch, ':'); idx >= 0 { - targetHeadBranch = targetHeadBranch[idx+1:] + if idx := strings.IndexRune(opts.HeadBranch, ':'); idx >= 0 { + targetHeadRepoOwner = opts.HeadBranch[:idx] + targetHeadBranch = opts.HeadBranch[idx+1:] } } else { // Use the current branch as the target local head branch when // --head is not provided. targetHeadBranch, err = opts.Branch() - headBranchLabel = targetHeadBranch if err != nil { return nil, fmt.Errorf("could not determine the current branch: %w", err) } @@ -666,11 +666,19 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { return nil, err } - // We received the head repository and branch from ParsePRRefs, but we - // need to check if it's up-to-date with our local branch state. + // If the --head provided contains : syntax, we need to use + // the provided owner instead of the owner of the base repository. + if targetHeadRepoOwner != "" { + prRefs.HeadRepo = ghrepo.New(targetHeadRepoOwner, prRefs.HeadRepo.RepoName()) + } + + // We received the head repository and branch from ParsePRRefs, or inferred + // it from --head input, but we need to check if it's up-to-date with + // our local branch state. // If it is, we can use it as the head remote for the PR // and avoid prompting the user. var headRemote *ghContext.Remote + var forkHeadRepo bool remoteHeadCurrent := isRemoteHeadCurrent(gitClient, prRefs, remotes) if remoteHeadCurrent && prRefs.HeadRepo != nil && prRefs.BranchName != "" { @@ -682,7 +690,6 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { if err != nil { return nil, err } - headBranchLabel = prRefs.GetPRHeadLabel() } else if isPushEnabled && opts.IO.CanPrompt() { // Since we could not determine a head ref, prompt the user for the head repository to push // using a list of repositories obtained from the API @@ -725,17 +732,14 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { if selectedOption < len(pushableRepos) { prRefs.HeadRepo = pushableRepos[selectedOption] - if !ghrepo.IsSame(prRefs.BaseRepo, prRefs.HeadRepo) { - headBranchLabel = fmt.Sprintf("%s:%s", prRefs.HeadRepo.RepoOwner(), prRefs.BranchName) - } } else if pushOptions[selectedOption] == "Skip pushing the branch" { isPushEnabled = false } else if pushOptions[selectedOption] == "Cancel" { return nil, cmdutil.CancelError } else { // "Create a fork of ..." - headBranchLabel = fmt.Sprintf("%s:%s", currentLogin, prRefs.BranchName) - prRefs.HeadRepo = nil + forkHeadRepo = true + prRefs.HeadRepo = ghrepo.New(currentLogin, prRefs.HeadRepo.RepoName()) } } @@ -764,9 +768,9 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { PrRefs: prRefs, BaseBranch: baseBranch, // Currently not supported by shared.PullRequestRefs struct BaseTrackingBranch: baseTrackingBranch, - HeadBranchLabel: headBranchLabel, HeadRemote: headRemote, isPushEnabled: isPushEnabled, + forkHeadRepo: forkHeadRepo, RepoContext: repoContext, Client: client, GitClient: gitClient, @@ -794,7 +798,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS "body": state.Body, "draft": state.Draft, "baseRefName": ctx.BaseBranch, - "headRefName": ctx.HeadBranchLabel, + "headRefName": ctx.PrRefs.GetPRHeadLabel(), "maintainerCanModify": opts.MaintainerCanModify, } @@ -921,7 +925,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { var err error // if a head repository could not be determined so far, automatically create // one by forking the base repository - if headRepo == nil && ctx.isPushEnabled { + if ctx.forkHeadRepo && ctx.isPushEnabled { opts.IO.StartProgressIndicator() headRepo, err = api.ForkRepo(client, ctx.PrRefs.BaseRepo, "", "", false) opts.IO.StopProgressIndicator() @@ -1038,7 +1042,7 @@ func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (str u := ghrepo.GenerateRepoURL( ctx.PrRefs.BaseRepo, "compare/%s...%s?expand=1", - url.PathEscape(ctx.BaseBranch), url.PathEscape(ctx.HeadBranchLabel)) + url.PathEscape(ctx.BaseBranch), url.PathEscape(ctx.PrRefs.GetPRHeadLabel())) url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PrRefs.BaseRepo, u, state) if err != nil { return "", err diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index a69ab3b3a..c07195d40 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1571,6 +1571,12 @@ func Test_createRun(t *testing.T) { opts.HeadBranch = "otherowner:feature" return func() {} }, + customPushDestination: true, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") + cs.Register("git config remote.pushDefault", 0, "") + cs.Register("git config push.default", 0, "") + }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", }, } @@ -1687,10 +1693,11 @@ func Test_generateCompareURL(t *testing.T) { name: "basic", ctx: CreateContext{ PrRefs: shared.PullRequestRefs{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + BranchName: "feature", }, - BaseBranch: "main", - HeadBranchLabel: "feature", + BaseBranch: "main", }, want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1", wantErr: false, @@ -1699,10 +1706,11 @@ func Test_generateCompareURL(t *testing.T) { name: "with labels", ctx: CreateContext{ PrRefs: shared.PullRequestRefs{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + BranchName: "b", }, - BaseBranch: "a", - HeadBranchLabel: "b", + BaseBranch: "a", }, state: shared.IssueMetadataState{ Labels: []string{"one", "two three"}, @@ -1714,12 +1722,13 @@ func Test_generateCompareURL(t *testing.T) { name: "'/'s in branch names/labels are percent-encoded", ctx: CreateContext{ PrRefs: shared.PullRequestRefs{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER-UPSTREAM"}}, "github.com"), + HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + BranchName: "feature", }, - BaseBranch: "main/trunk", - HeadBranchLabel: "owner:feature", + BaseBranch: "main/trunk", }, - want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner:feature?body=&expand=1", + want: "https://github.com/OWNER-UPSTREAM/REPO/compare/main%2Ftrunk...OWNER:feature?body=&expand=1", wantErr: false, }, { @@ -1732,22 +1741,26 @@ func Test_generateCompareURL(t *testing.T) { */ ctx: CreateContext{ PrRefs: shared.PullRequestRefs{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER-UPSTREAM"}}, "github.com"), + HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + BranchName: "!$&'()+,;=@", }, - BaseBranch: "main/trunk", - HeadBranchLabel: "owner:!$&'()+,;=@", + BaseBranch: "main/trunk", + //TODO check this + // HeadBranchLabel: "owner:!$&'()+,;=@", }, - want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner:%21$&%27%28%29+%2C%3B=@?body=&expand=1", + want: "https://github.com/OWNER-UPSTREAM/REPO/compare/main%2Ftrunk...OWNER:%21$&%27%28%29+%2C%3B=@?body=&expand=1", wantErr: false, }, { name: "with template", ctx: CreateContext{ PrRefs: shared.PullRequestRefs{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + BranchName: "feature", }, - BaseBranch: "main", - HeadBranchLabel: "feature", + BaseBranch: "main", }, state: shared.IssueMetadataState{ Template: "story.md", From f50dac53cb49244226297eef1e2e713194d10c78 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 5 Mar 2025 14:10:31 -0700 Subject: [PATCH 113/147] docs(pr create): fix : desc. --- pkg/cmd/pr/create/create.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 371b14dbe..d6adf7006 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -629,7 +629,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { isPushEnabled = false targetHeadBranch = opts.HeadBranch // If the --head provided contains a colon, that means - // this is : syntax. + // this is : syntax. if idx := strings.IndexRune(opts.HeadBranch, ':'); idx >= 0 { targetHeadRepoOwner = opts.HeadBranch[:idx] targetHeadBranch = opts.HeadBranch[idx+1:] @@ -666,7 +666,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { return nil, err } - // If the --head provided contains : syntax, we need to use + // If the --head provided contains : syntax, we need to use // the provided owner instead of the owner of the base repository. if targetHeadRepoOwner != "" { prRefs.HeadRepo = ghrepo.New(targetHeadRepoOwner, prRefs.HeadRepo.RepoName()) @@ -675,7 +675,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { // We received the head repository and branch from ParsePRRefs, or inferred // it from --head input, but we need to check if it's up-to-date with // our local branch state. - // If it is, we can use it as the head remote for the PR + // If it is, we can use it as the head repo for the PR // and avoid prompting the user. var headRemote *ghContext.Remote var forkHeadRepo bool From 9f1cb0cd54bfc566a2af209e8c7d7ffdb881cb26 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:05:15 -0700 Subject: [PATCH 114/147] refactor(pr create): simplify head remote logic --- pkg/cmd/pr/create/create.go | 72 +++++++++++++++---------------------- 1 file changed, 28 insertions(+), 44 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index d6adf7006..762636797 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -516,36 +516,6 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, u return nil } -// isRemoteHeadCurrent returns true if the remote head is on the same sha as the local head. -// This is used to determine if we might need to push the local head branch to the remote. -func isRemoteHeadCurrent(gitClient *git.Client, prRefs shared.PullRequestRefs, remotes ghContext.Remotes) bool { - headRemote, err := remotes.FindByRepo(prRefs.HeadRepo.RepoOwner(), prRefs.HeadRepo.RepoName()) - if err != nil { - return false - } - - refsForLookup := []string{"HEAD", fmt.Sprintf("refs/remotes/%s/%s", headRemote, prRefs.BranchName)} - resolvedRefs, err := gitClient.ShowRefs(context.Background(), refsForLookup) - if err != nil { - return false - } - - // If there is more than one resolved ref, then remote head ref was resolved. - if len(resolvedRefs) > 1 { - headRef := resolvedRefs[0] - for _, r := range resolvedRefs[1:] { - // If the head ref is not the same as the remote head ref, then the remote head is not current. - if r.Hash != headRef.Hash { - continue - } - - return true - } - } - - return false -} - func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadataState, error) { var milestoneTitles []string if opts.Milestone != "" { @@ -623,10 +593,10 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { var targetHeadBranch string var targetHeadRepoOwner string - isPushEnabled := true + promptForHeadRepo := true if opts.HeadBranch != "" { - isPushEnabled = false + promptForHeadRepo = false targetHeadBranch = opts.HeadBranch // If the --head provided contains a colon, that means // this is : syntax. @@ -672,25 +642,39 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { prRefs.HeadRepo = ghrepo.New(targetHeadRepoOwner, prRefs.HeadRepo.RepoName()) } + var headRemote *ghContext.Remote + // We received the head repository and branch from ParsePRRefs, or inferred // it from --head input, but we need to check if it's up-to-date with // our local branch state. // If it is, we can use it as the head repo for the PR // and avoid prompting the user. - var headRemote *ghContext.Remote - var forkHeadRepo bool + if prRefs.HeadRepo != nil && prRefs.BranchName != "" { + headRemote, err := remotes.FindByRepo(prRefs.HeadRepo.RepoOwner(), prRefs.HeadRepo.RepoName()) + if err == nil { + refsForLookup := []string{"HEAD", fmt.Sprintf("refs/remotes/%s/%s", headRemote, prRefs.BranchName)} + resolvedRefs, err := gitClient.ShowRefs(context.Background(), refsForLookup) - remoteHeadCurrent := isRemoteHeadCurrent(gitClient, prRefs, remotes) - if remoteHeadCurrent && prRefs.HeadRepo != nil && prRefs.BranchName != "" { - isPushEnabled = false - headRemote, err = remotes.FindByRepo(prRefs.HeadRepo.RepoOwner(), prRefs.HeadRepo.RepoName()) - // TODO: KW what does an err here mean? - // If we fail to find a remote for that repo, shouldn't we just try to prompt - // for head repos? - if err != nil { - return nil, err + // If there is more than one resolved ref, then remote head ref was resolved. + if err == nil && len(resolvedRefs) > 1 { + headRef := resolvedRefs[0] + for _, r := range resolvedRefs[1:] { + // If the head ref is the same as the remote head ref, + // then the remote head is current. + if r.Hash == headRef.Hash { + promptForHeadRepo = false + break + } + } + } } - } else if isPushEnabled && opts.IO.CanPrompt() { + } + + var forkHeadRepo bool + var isPushEnabled bool + + if promptForHeadRepo && opts.IO.CanPrompt() { + isPushEnabled = true // Since we could not determine a head ref, prompt the user for the head repository to push // using a list of repositories obtained from the API pushableRepos, err := repoContext.HeadRepos() From cf58910ac0a2a7ee52b38b0a31c1eebae53db122 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:11:50 -0700 Subject: [PATCH 115/147] refactor(pr create): named headRefName var --- pkg/cmd/pr/create/create.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 762636797..45c7c4499 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -652,7 +652,8 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { if prRefs.HeadRepo != nil && prRefs.BranchName != "" { headRemote, err := remotes.FindByRepo(prRefs.HeadRepo.RepoOwner(), prRefs.HeadRepo.RepoName()) if err == nil { - refsForLookup := []string{"HEAD", fmt.Sprintf("refs/remotes/%s/%s", headRemote, prRefs.BranchName)} + headRefName := fmt.Sprintf("refs/remotes/%s/%s", headRemote, prRefs.BranchName) + refsForLookup := []string{"HEAD", headRefName} resolvedRefs, err := gitClient.ShowRefs(context.Background(), refsForLookup) // If there is more than one resolved ref, then remote head ref was resolved. @@ -672,7 +673,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { var forkHeadRepo bool var isPushEnabled bool - + if promptForHeadRepo && opts.IO.CanPrompt() { isPushEnabled = true // Since we could not determine a head ref, prompt the user for the head repository to push From 911079c744d97c5f03a934bd8ebebd54f9f7b627 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:15:31 -0700 Subject: [PATCH 116/147] docs(pr create): add comments --- pkg/cmd/pr/create/create.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 45c7c4499..46f3db8af 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -650,6 +650,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { // If it is, we can use it as the head repo for the PR // and avoid prompting the user. if prRefs.HeadRepo != nil && prRefs.BranchName != "" { + // Check if the head branch is up-to-date with the local branch headRemote, err := remotes.FindByRepo(prRefs.HeadRepo.RepoOwner(), prRefs.HeadRepo.RepoName()) if err == nil { headRefName := fmt.Sprintf("refs/remotes/%s/%s", headRemote, prRefs.BranchName) From ba6e2ec3ed43ca673cc78ffd8e93cdb702e049a8 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 5 Mar 2025 16:20:31 -0700 Subject: [PATCH 117/147] test(pr create): skip tests that create forks --- .../testdata/pr/pr-create-respects-branch-pushremote.txtar | 2 +- .../testdata/pr/pr-create-respects-push-destination.txtar | 2 +- .../pr/pr-create-respects-remote-colon-branch-syntax.txtar | 2 +- .../testdata/pr/pr-create-respects-remote-pushdefault.txtar | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar index 0acdbc4fb..f2659ecef 100644 --- a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar +++ b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar @@ -1,4 +1,4 @@ -# skip 'it creates a fork owned by the user running the test' +skip 'it creates a fork owned by the user running the test' # Setup environment variables used for testscript env REPO=${SCRIPT_NAME}-${RANDOM_STRING} diff --git a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar index 85b194634..6a95ad817 100644 --- a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar +++ b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar @@ -1,4 +1,4 @@ -# skip 'it creates a fork owned by the user running the test' +skip 'it creates a fork owned by the user running the test' # Setup environment variables used for testscript env REPO=${SCRIPT_NAME}-${RANDOM_STRING} diff --git a/acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar b/acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar index fe2988699..acf9bca14 100644 --- a/acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar +++ b/acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar @@ -1,4 +1,4 @@ -# skip 'it creates a fork owned by the user running the test' +skip 'it creates a fork owned by the user running the test' # Setup environment variables used for testscript env REPO=${SCRIPT_NAME}-${RANDOM_STRING} diff --git a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar index 660214071..6e223f5a6 100644 --- a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar @@ -1,4 +1,4 @@ -# skip 'it creates a fork owned by the user running the test' +skip 'it creates a fork owned by the user running the test' # Setup environment variables used for testscript env REPO=${SCRIPT_NAME}-${RANDOM_STRING} From 9b96e6cded51616fba8a2553c429b127f636410d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:34:28 -0700 Subject: [PATCH 118/147] doc(pr create): fix typo in test comments Co-authored-by: Tyler McGoffin --- pkg/cmd/pr/create/create_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index c07195d40..d515f3a6c 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1599,8 +1599,8 @@ func Test_createRun(t *testing.T) { cs, cmdTeardown := run.Stub() defer cmdTeardown(t) cs.Register(`git status --porcelain`, 0, "") - // TODO this could be be values in the test struct with a helper - // function to invoke the apporpriate command stubs based on + // TODO this could be values in the test struct with a helper + // function to invoke the appropriate command stubs based on // those values. if !tt.customPushDestination { cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") From da235b134b5b16a65a134a20285426062fd6d110 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 6 Mar 2025 13:40:11 -0700 Subject: [PATCH 119/147] tests(pr create): remove irrelevant comments --- pkg/cmd/pr/create/create_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index d515f3a6c..25559f360 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1746,8 +1746,6 @@ func Test_generateCompareURL(t *testing.T) { BranchName: "!$&'()+,;=@", }, BaseBranch: "main/trunk", - //TODO check this - // HeadBranchLabel: "owner:!$&'()+,;=@", }, want: "https://github.com/OWNER-UPSTREAM/REPO/compare/main%2Ftrunk...OWNER:%21$&%27%28%29+%2C%3B=@?body=&expand=1", wantErr: false, From 1ce3d992270ebc24aee2ef29c067b8b17e90ad61 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:03:55 -0600 Subject: [PATCH 120/147] test(pr create): add logical spacing between operations Co-authored-by: Andy Feller --- .../testdata/pr/pr-create-respects-branch-pushremote.txtar | 2 ++ .../testdata/pr/pr-create-respects-push-destination.txtar | 2 ++ .../pr/pr-create-respects-remote-colon-branch-syntax.txtar | 2 ++ .../testdata/pr/pr-create-respects-remote-pushdefault.txtar | 2 ++ 4 files changed, 8 insertions(+) diff --git a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar index f2659ecef..c191fba2e 100644 --- a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar +++ b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar @@ -22,6 +22,8 @@ exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} # Defer repo cleanup of fork defer gh repo delete --yes ${USER}/${FORK} + +# Retrieve fork repository information sleep 5 exec gh repo view ${USER}/${FORK} --json id --jq '.id' stdout2env FORK_ID diff --git a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar index 6a95ad817..142a2ec35 100644 --- a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar +++ b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar @@ -22,6 +22,8 @@ exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} # Defer repo cleanup of fork defer gh repo delete --yes ${USER}/${FORK} + +# Retrieve fork repository information sleep 5 exec gh repo view ${USER}/${FORK} --json id --jq '.id' stdout2env FORK_ID diff --git a/acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar b/acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar index acf9bca14..47d6f5e5b 100644 --- a/acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar +++ b/acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar @@ -22,6 +22,8 @@ exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} # Defer repo cleanup of fork defer gh repo delete --yes ${USER}/${FORK} + +# Retrieve fork repository information sleep 5 exec gh repo view ${USER}/${FORK} --json id --jq '.id' stdout2env FORK_ID diff --git a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar index 6e223f5a6..323f955f4 100644 --- a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar @@ -22,6 +22,8 @@ exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} # Defer repo cleanup of fork defer gh repo delete --yes ${USER}/${FORK} + +# Retrieve fork repository information sleep 5 exec gh repo view ${USER}/${FORK} --json id --jq '.id' stdout2env FORK_ID From bab7dc650b1b654955ee4e63ebb20edcef8228fe Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:29:53 -0600 Subject: [PATCH 121/147] test(pr create): update repo owner names in tests --- pkg/cmd/pr/create/create_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 25559f360..049d93f0e 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1722,13 +1722,13 @@ func Test_generateCompareURL(t *testing.T) { name: "'/'s in branch names/labels are percent-encoded", ctx: CreateContext{ PrRefs: shared.PullRequestRefs{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER-UPSTREAM"}}, "github.com"), - HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"), + HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "ORIGINOWNER"}}, "github.com"), BranchName: "feature", }, BaseBranch: "main/trunk", }, - want: "https://github.com/OWNER-UPSTREAM/REPO/compare/main%2Ftrunk...OWNER:feature?body=&expand=1", + want: "https://github.com/UPSTREAMOWNER/REPO/compare/main%2Ftrunk...ORIGINOWNER:feature?body=&expand=1", wantErr: false, }, { @@ -1741,13 +1741,13 @@ func Test_generateCompareURL(t *testing.T) { */ ctx: CreateContext{ PrRefs: shared.PullRequestRefs{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER-UPSTREAM"}}, "github.com"), - HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"), + HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "ORIGINOWNER"}}, "github.com"), BranchName: "!$&'()+,;=@", }, BaseBranch: "main/trunk", }, - want: "https://github.com/OWNER-UPSTREAM/REPO/compare/main%2Ftrunk...OWNER:%21$&%27%28%29+%2C%3B=@?body=&expand=1", + want: "https://github.com/UPSTREAMOWNER/REPO/compare/main%2Ftrunk...ORIGINOWNER:%21$&%27%28%29+%2C%3B=@?body=&expand=1", wantErr: false, }, { From 54da786bec22bb64f298bacb1b72a721ddca2f24 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 10 Mar 2025 13:06:16 -0600 Subject: [PATCH 122/147] fix(pr create): update error handling --- pkg/cmd/pr/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 46f3db8af..dc89542e5 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -652,7 +652,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { if prRefs.HeadRepo != nil && prRefs.BranchName != "" { // Check if the head branch is up-to-date with the local branch headRemote, err := remotes.FindByRepo(prRefs.HeadRepo.RepoOwner(), prRefs.HeadRepo.RepoName()) - if err == nil { + if headRemote != nil && err == nil { headRefName := fmt.Sprintf("refs/remotes/%s/%s", headRemote, prRefs.BranchName) refsForLookup := []string{"HEAD", headRefName} resolvedRefs, err := gitClient.ShowRefs(context.Background(), refsForLookup) From e999976b3dbbad2072926bf0238a3f15a230210a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 10 Mar 2025 13:28:46 -0600 Subject: [PATCH 123/147] refactor(pr create): add PullRequestRefs `HasHead` --- pkg/cmd/pr/create/create.go | 4 +-- pkg/cmd/pr/shared/finder.go | 4 +++ pkg/cmd/pr/shared/finder_test.go | 46 ++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index dc89542e5..04cf5d1aa 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -649,7 +649,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { // our local branch state. // If it is, we can use it as the head repo for the PR // and avoid prompting the user. - if prRefs.HeadRepo != nil && prRefs.BranchName != "" { + if prRefs.HasHead() { // Check if the head branch is up-to-date with the local branch headRemote, err := remotes.FindByRepo(prRefs.HeadRepo.RepoOwner(), prRefs.HeadRepo.RepoName()) if headRemote != nil && err == nil { @@ -662,7 +662,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { headRef := resolvedRefs[0] for _, r := range resolvedRefs[1:] { // If the head ref is the same as the remote head ref, - // then the remote head is current. + // then the remote head is current, and we can use it. if r.Hash == headRef.Hash { promptForHeadRepo = false break diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index dc9cb8fb9..d02377759 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -109,6 +109,10 @@ type PullRequestRefs struct { BaseRepo ghrepo.Interface } +func (s *PullRequestRefs) HasHead() bool { + return s.HeadRepo != nil && s.BranchName != "" +} + // GetPRHeadLabel returns the string that the GitHub API uses to identify the PR. This is // either just the branch name or, if the PR is originating from a fork, the fork owner // and the branch name, like :. diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 36551ab42..b10c55e55 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -972,6 +972,52 @@ func TestPRRefs_GetPRHeadLabel(t *testing.T) { } } +func TestPullRequestRefs_HasHead(t *testing.T) { + tests := []struct { + name string + prRefs PullRequestRefs + want bool + }{ + { + name: "HeadRepo is nil and BranchName is empty, return false", + prRefs: PullRequestRefs{ + HeadRepo: nil, + BranchName: "", + }, + want: false, + }, + { + name: "HeadRepo is not nil and BranchName is empty, return false", + prRefs: PullRequestRefs{ + HeadRepo: ghrepo.New("ORIGINOWNER", "REPO"), + BranchName: "", + }, + want: false, + }, + { + name: "HeadRepo is nil and BranchName is not empty, return false", + prRefs: PullRequestRefs{ + HeadRepo: nil, + BranchName: "feature-branch", + }, + want: false, + }, + { + name: "HeadRepo is not nil and BranchName is not empty, return true", + prRefs: PullRequestRefs{ + HeadRepo: ghrepo.New("ORIGINOWNER", "REPO"), + BranchName: "feature-branch", + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.prRefs.HasHead()) + }) + } +} + func stubBranchConfig(branchConfig git.BranchConfig, err error) func(string) (git.BranchConfig, error) { return func(branch string) (git.BranchConfig, error) { return branchConfig, err From d524cbddc255cc454469d84d861da5a72f38500c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 10 Mar 2025 14:55:39 -0600 Subject: [PATCH 124/147] docs(pr create): clarify comment on head branch syntax --- pkg/cmd/pr/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 04cf5d1aa..fa6373b41 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -599,7 +599,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { promptForHeadRepo = false targetHeadBranch = opts.HeadBranch // If the --head provided contains a colon, that means - // this is : syntax. + // this is : syntax. if idx := strings.IndexRune(opts.HeadBranch, ':'); idx >= 0 { targetHeadRepoOwner = opts.HeadBranch[:idx] targetHeadBranch = opts.HeadBranch[idx+1:] From e9e57f3ee38f0bc6a049dd2ed61ccaab0c8bc53a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 10 Mar 2025 15:38:37 -0600 Subject: [PATCH 125/147] doc(pr create): fix typo in comments Co-authored-by: Tyler McGoffin --- pkg/cmd/pr/create/create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index fa6373b41..24f53aaaa 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -662,7 +662,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { headRef := resolvedRefs[0] for _, r := range resolvedRefs[1:] { // If the head ref is the same as the remote head ref, - // then the remote head is current, and we can use it. + // then the remote head is current and we can use it. if r.Hash == headRef.Hash { promptForHeadRepo = false break From 6dae35b442e13fb6836d0d2412107482f51aa790 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:04:57 -0600 Subject: [PATCH 126/147] test(finder): change assert to require Co-authored-by: Andy Feller --- pkg/cmd/pr/shared/finder_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index b10c55e55..1af7d83ed 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -1013,7 +1013,7 @@ func TestPullRequestRefs_HasHead(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, tt.prRefs.HasHead()) + require.Equal(t, tt.want, tt.prRefs.HasHead()) }) } } From 041f02c9805423f0c0d460802ab8317db36b09ef Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:10:45 -0600 Subject: [PATCH 127/147] docs(pr create): standard : syntax Standardize : syntax wherever it is described in comments. --- pkg/cmd/pr/create/create.go | 4 ++-- pkg/cmd/pr/create/create_test.go | 2 +- pkg/cmd/pr/shared/finder.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 24f53aaaa..958de2c1f 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -599,7 +599,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { promptForHeadRepo = false targetHeadBranch = opts.HeadBranch // If the --head provided contains a colon, that means - // this is : syntax. + // this is : syntax. if idx := strings.IndexRune(opts.HeadBranch, ':'); idx >= 0 { targetHeadRepoOwner = opts.HeadBranch[:idx] targetHeadBranch = opts.HeadBranch[idx+1:] @@ -636,7 +636,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { return nil, err } - // If the --head provided contains : syntax, we need to use + // If the --head provided contains : syntax, we need to use // the provided owner instead of the owner of the base repository. if targetHeadRepoOwner != "" { prRefs.HeadRepo = ghrepo.New(targetHeadRepoOwner, prRefs.HeadRepo.RepoName()) diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 049d93f0e..cfe6193c9 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1547,7 +1547,7 @@ func Test_createRun(t *testing.T) { expectedErrOut: "\nCreating pull request for monalisa:task1 into feature/feat2 in OWNER/REPO\n\n", }, { - name: "--head contains : syntax", + name: "--head contains : syntax", httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index d02377759..7fed231cb 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -115,7 +115,7 @@ func (s *PullRequestRefs) HasHead() bool { // GetPRHeadLabel returns the string that the GitHub API uses to identify the PR. This is // either just the branch name or, if the PR is originating from a fork, the fork owner -// and the branch name, like :. +// and the branch name, like :. func (s *PullRequestRefs) GetPRHeadLabel() string { if ghrepo.IsSame(s.HeadRepo, s.BaseRepo) { return s.BranchName From 7bb5d71f4cfd5cf9b70d61abc9a8ef2f4b9335c4 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:13:34 -0600 Subject: [PATCH 128/147] doc(pr create): improve head repo resolution comments Co-authored-by: Andy Feller --- pkg/cmd/pr/create/create.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 958de2c1f..9bb15c162 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -649,6 +649,8 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { // our local branch state. // If it is, we can use it as the head repo for the PR // and avoid prompting the user. + // Errors raised here should not cause command to fail, + // prompt user for head repo if an error is raised or no remote found. if prRefs.HasHead() { // Check if the head branch is up-to-date with the local branch headRemote, err := remotes.FindByRepo(prRefs.HeadRepo.RepoOwner(), prRefs.HeadRepo.RepoName()) From 15ea861b793eaf16bdb4b9151e36c6a96cb84942 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:18:03 -0600 Subject: [PATCH 129/147] docs(pr create): help text, doc user:branch syntax Document the user:branch syntax for the `--head`` flag in `gh pr create`. --- pkg/cmd/pr/create/create.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 9bb15c162..5051d30e6 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -111,6 +111,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co to push the branch and offer an option to fork the base repository. Use %[1]s--head%[1]s to explicitly skip any forking or pushing behavior. + %[1]s--head%[1]s supports %[1]s:%[1]s syntax to select a head repo owned by %[1]s%[1]s. + Using an organization as the %[1]s%[1]s is currently not supported. + A prompt will also ask for the title and the body of the pull request. Use %[1]s--title%[1]s and %[1]s--body%[1]s to skip this, or use %[1]s--fill%[1]s to autofill these values from git commits. It's important to notice that if the %[1]s--title%[1]s and/or %[1]s--body%[1]s are also provided From 81d00a36f8af477ebdb41e33046d749d37aae8d8 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:22:15 -0600 Subject: [PATCH 130/147] fix(pr create): use existing local variables Co-authored-by: Andy Feller --- pkg/cmd/pr/create/create.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 5051d30e6..778a1f269 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -565,7 +565,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { } gitClient := opts.GitClient - if ucc, err := gitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 { + if ucc, err := gitClient.UncommittedChangeCount(ctx); err == nil && ucc > 0 { fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", text.Pluralize(ucc, "uncommitted change")) } @@ -616,7 +616,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { } } - targetHeadBranchConfig, err := gitClient.ReadBranchConfig(context.Background(), targetHeadBranch) + targetHeadBranchConfig, err := gitClient.ReadBranchConfig(ctx, targetHeadBranch) if err != nil { return nil, err } @@ -628,8 +628,8 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { return nil, err } // Suppressing these errors as we have other means of computing the PullRequestRefs when these fail. - parsedPushRevision, _ := opts.GitClient.ParsePushRevision(ctx, targetHeadBranch) - pushDefault, err := opts.GitClient.PushDefault(ctx) + parsedPushRevision, _ := gitClient.ParsePushRevision(ctx, targetHeadBranch) + pushDefault, err := gitClient.PushDefault(ctx) if err != nil { return nil, err } @@ -660,7 +660,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { if headRemote != nil && err == nil { headRefName := fmt.Sprintf("refs/remotes/%s/%s", headRemote, prRefs.BranchName) refsForLookup := []string{"HEAD", headRefName} - resolvedRefs, err := gitClient.ShowRefs(context.Background(), refsForLookup) + resolvedRefs, err := gitClient.ShowRefs(ctx, refsForLookup) // If there is more than one resolved ref, then remote head ref was resolved. if err == nil && len(resolvedRefs) > 1 { From 188e1388b380657f05716327cc24a1f112746d1d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:47:22 -0600 Subject: [PATCH 131/147] test(pr create): add AT for no local repo --- .../testdata/pr/pr-create-no-local-repo.txtar | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 acceptance/testdata/pr/pr-create-no-local-repo.txtar diff --git a/acceptance/testdata/pr/pr-create-no-local-repo.txtar b/acceptance/testdata/pr/pr-create-no-local-repo.txtar new file mode 100644 index 000000000..931d9e216 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-no-local-repo.txtar @@ -0,0 +1,24 @@ +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Prepare a branch to PR +cd $SCRIPT_NAME-$RANDOM_STRING +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Leave the repo so there's no local repo +cd $WORK + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING --head feature-branch +stdout https://${GH_HOST}/${ORG}/${SCRIPT_NAME}-${RANDOM_STRING}/pull/1 From bf7bf99f5442a60cd20338c8f71018c3ef763011 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 12 Mar 2025 07:10:51 -0600 Subject: [PATCH 132/147] fix(pr create & stubs): handle exitcode in stubs --- git/command.go | 13 ++++++++++++- pkg/cmd/pr/create/create_test.go | 6 +++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/git/command.go b/git/command.go index 8065ffd86..c4614d086 100644 --- a/git/command.go +++ b/git/command.go @@ -43,10 +43,21 @@ func (gc *Command) Output() ([]byte, error) { out, err := run.PrepareCmd(gc.Cmd).Output() if err != nil { ge := GitError{err: err} + + // In real implementation, this should be an exec.ExitError, as below, + // but the tests use a different type because exec.ExitError are difficult + // to create. We want to get the exit code and stderr, but stderr + // is not a method and so tests can't access it. + // THIS MEANS THAT TESTS WILL NOT CORRECTLY HAVE STDERR SET, + // but at least tests can get the exit code. + var exitErrorWithExitCode errWithExitCode + if errors.As(err, &exitErrorWithExitCode) { + ge.ExitCode = exitErrorWithExitCode.ExitCode() + } + var exitError *exec.ExitError if errors.As(err, &exitError) { ge.Stderr = string(exitError.Stderr) - ge.ExitCode = exitError.ExitCode() } err = &ge } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index cfe6193c9..51cbfa724 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1304,9 +1304,9 @@ func Test_createRun(t *testing.T) { }, customPushDestination: true, cmdStubs: func(cs *run.CommandStubber) { - cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "") - cs.Register("git config remote.pushDefault", 0, "") - cs.Register("git config push.default", 0, "") + cs.Register("git rev-parse --abbrev-ref feature@{push}", 1, "fatal: not a git repository (or any of the parent directories): .git") + cs.Register("git config remote.pushDefault", 1, "") + cs.Register("git config push.default", 1, "") }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", }, From 0611d9d06df76ef63f37ebf301d2fa85de2e7f15 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 12 Mar 2025 10:31:21 -0600 Subject: [PATCH 133/147] test(pr create): fix user:branch syntax AT This test was setting `remote.pushDefault` which likely caused the `user:branch` syntax to be ignored. Update the test to not set this config value. --- ...ar => pr-create-respects-user-colon-branch-syntax.txtar} | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) rename acceptance/testdata/pr/{pr-create-respects-remote-colon-branch-syntax.txtar => pr-create-respects-user-colon-branch-syntax.txtar} (89%) diff --git a/acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar b/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar similarity index 89% rename from acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar rename to acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar index 47d6f5e5b..d9b22a023 100644 --- a/acceptance/testdata/pr/pr-create-respects-remote-colon-branch-syntax.txtar +++ b/acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar @@ -34,15 +34,13 @@ cd ${FORK} # Prepare a branch where changes are pulled from the upstream default branch but pushed to fork exec git checkout -b feature-branch upstream/main -exec git config remote.pushDefault origin -exec git config unset remote.upstream.gh-resolved exec git commit --allow-empty -m 'Empty Commit' -exec git push +exec git push origin feature-branch # Create the PR spanning upstream and fork repositories exec gh pr create --title 'Feature Title' --body 'Feature Body' --head ${USER}:feature-branch stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs -exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository +exec gh pr view ${USER}:feature-branch --json headRefName,headRepository,baseRefName,isCrossRepository stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} \ No newline at end of file From 76de5f07caaadcf2d64856107a3937c7cc1e8473 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 12 Mar 2025 10:37:28 -0600 Subject: [PATCH 134/147] test(pr finder): run test in parallel --- pkg/cmd/pr/shared/finder_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 1af7d83ed..3349197e2 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -1013,6 +1013,7 @@ func TestPullRequestRefs_HasHead(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() require.Equal(t, tt.want, tt.prRefs.HasHead()) }) } From 6004fc2cd89c5cdf9c749cbfd647aca2d96ab949 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 12 Mar 2025 10:39:25 -0600 Subject: [PATCH 135/147] fix(pr create): use curly brace for vars in AT --- .../testdata/pr/pr-create-no-local-repo.txtar | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/acceptance/testdata/pr/pr-create-no-local-repo.txtar b/acceptance/testdata/pr/pr-create-no-local-repo.txtar index 931d9e216..a6e06cdcd 100644 --- a/acceptance/testdata/pr/pr-create-no-local-repo.txtar +++ b/acceptance/testdata/pr/pr-create-no-local-repo.txtar @@ -2,23 +2,23 @@ exec gh auth setup-git # Create a repository with a file so it has a default branch -exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private +exec gh repo create ${ORG}/${SCRIPT_NAME}-${RANDOM_STRING} --add-readme --private # Defer repo cleanup -defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING +defer gh repo delete --yes ${ORG}/${SCRIPT_NAME}-${RANDOM_STRING} # Clone the repo -exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING +exec gh repo clone ${ORG}/${SCRIPT_NAME}-${RANDOM_STRING} # Prepare a branch to PR -cd $SCRIPT_NAME-$RANDOM_STRING +cd ${SCRIPT_NAME}-${RANDOM_STRING} exec git checkout -b feature-branch exec git commit --allow-empty -m 'Empty Commit' exec git push -u origin feature-branch # Leave the repo so there's no local repo -cd $WORK +cd ${WORK} # Create the PR -exec gh pr create --title 'Feature Title' --body 'Feature Body' --repo $ORG/$SCRIPT_NAME-$RANDOM_STRING --head feature-branch -stdout https://${GH_HOST}/${ORG}/${SCRIPT_NAME}-${RANDOM_STRING}/pull/1 +exec gh pr create --title 'Feature Title' --body 'Feature Body' --repo ${ORG}/${SCRIPT_NAME}-${RANDOM_STRING} --head feature-branch +stdout https://${GH_HOST}/${ORG}/${SCRIPT_NAME}-${RANDOM_STRING}/pull/1 \ No newline at end of file From bdfec5018608302a9f2fbcac57581b343427f612 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 12 Mar 2025 10:43:06 -0600 Subject: [PATCH 136/147] fix(pr create): use REPO var in AT --- .../testdata/pr/pr-create-no-local-repo.txtar | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/acceptance/testdata/pr/pr-create-no-local-repo.txtar b/acceptance/testdata/pr/pr-create-no-local-repo.txtar index a6e06cdcd..cb42d99f8 100644 --- a/acceptance/testdata/pr/pr-create-no-local-repo.txtar +++ b/acceptance/testdata/pr/pr-create-no-local-repo.txtar @@ -1,17 +1,20 @@ # Use gh as a credential helper exec gh auth setup-git +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + # Create a repository with a file so it has a default branch -exec gh repo create ${ORG}/${SCRIPT_NAME}-${RANDOM_STRING} --add-readme --private +exec gh repo create ${ORG}/${REPO} --add-readme --private # Defer repo cleanup -defer gh repo delete --yes ${ORG}/${SCRIPT_NAME}-${RANDOM_STRING} +defer gh repo delete --yes ${ORG}/${REPO} # Clone the repo -exec gh repo clone ${ORG}/${SCRIPT_NAME}-${RANDOM_STRING} +exec gh repo clone ${ORG}/${REPO} # Prepare a branch to PR -cd ${SCRIPT_NAME}-${RANDOM_STRING} +cd ${REPO} exec git checkout -b feature-branch exec git commit --allow-empty -m 'Empty Commit' exec git push -u origin feature-branch @@ -20,5 +23,5 @@ exec git push -u origin feature-branch cd ${WORK} # Create the PR -exec gh pr create --title 'Feature Title' --body 'Feature Body' --repo ${ORG}/${SCRIPT_NAME}-${RANDOM_STRING} --head feature-branch -stdout https://${GH_HOST}/${ORG}/${SCRIPT_NAME}-${RANDOM_STRING}/pull/1 \ No newline at end of file +exec gh pr create --title 'Feature Title' --body 'Feature Body' --repo ${ORG}/${REPO} --head feature-branch +stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 \ No newline at end of file From 339e1a25f3ce11ae7469d19fce72cf6fcbbd5147 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 12 Mar 2025 12:06:43 -0600 Subject: [PATCH 137/147] docs(pr create): add link for user:branch syntax --- pkg/cmd/pr/create/create.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 778a1f269..646e58abd 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -113,6 +113,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co %[1]s--head%[1]s supports %[1]s:%[1]s syntax to select a head repo owned by %[1]s%[1]s. Using an organization as the %[1]s%[1]s is currently not supported. + For more information, see A prompt will also ask for the title and the body of the pull request. Use %[1]s--title%[1]s and %[1]s--body%[1]s to skip this, or use %[1]s--fill%[1]s to autofill these values from git commits. From 028caa3823c60c5752c73b9c03c94db3f3f8163b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 12 Mar 2025 12:11:15 -0600 Subject: [PATCH 138/147] docs(pr create): clarify BaseRepo type in submitPR --- pkg/cmd/pr/create/create.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 646e58abd..8ea0b48db 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -812,6 +812,8 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS } opts.IO.StartProgressIndicator() + // At this point, ctx.PrRefs.BaseRepo is guaranteed to be an *api.Repository + // because of https://github.com/cli/cli/blob/d29db2d44199ad4a987ea866f3f4ff601b1c90a0/pkg/cmd/pr/create/create.go#L578-L592 pr, err := api.CreatePullRequest(client, ctx.PrRefs.BaseRepo.(*api.Repository), params) opts.IO.StopProgressIndicator() if pr != nil { From ebd147b43ee98e44139adfcfac83ee807fba0655 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 13 Mar 2025 14:59:19 +0100 Subject: [PATCH 139/147] Use verbose upstream setting --- .../testdata/pr/pr-create-respects-branch-pushremote.txtar | 5 +++-- .../testdata/pr/pr-create-respects-remote-pushdefault.txtar | 5 +++-- .../testdata/pr/pr-create-respects-simple-pushdefault.txtar | 3 ++- .../pr/pr-create-respects-user-colon-branch-syntax.txtar | 5 +++-- .../pr/pr-view-status-respects-branch-pushremote.txtar | 3 ++- .../pr/pr-view-status-respects-remote-pushdefault.txtar | 3 ++- .../pr/pr-view-status-respects-simple-pushdefault.txtar | 3 ++- 7 files changed, 17 insertions(+), 10 deletions(-) diff --git a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar index c191fba2e..189caaf9e 100644 --- a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar +++ b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar @@ -33,7 +33,8 @@ exec gh repo clone ${USER}/${FORK} cd ${FORK} # Prepare a branch where changes are pulled from the upstream default branch but pushed to fork -exec git checkout -b feature-branch upstream/main +exec git checkout -b feature-branch +exec git branch --set-upstream-to upstream/main exec git config branch.feature-branch.pushRemote origin exec git config unset remote.upstream.gh-resolved exec git commit --allow-empty -m 'Empty Commit' @@ -45,4 +46,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} \ No newline at end of file +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar index 323f955f4..2b4b28809 100644 --- a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar @@ -33,7 +33,8 @@ exec gh repo clone ${USER}/${FORK} cd ${FORK} # Prepare a branch where changes are pulled from the upstream default branch but pushed to fork -exec git checkout -b feature-branch upstream/main +exec git checkout -b feature-branch +exec git branch --set-upstream-to upstream/main exec git config remote.pushDefault origin exec git config unset remote.upstream.gh-resolved exec git commit --allow-empty -m 'Empty Commit' @@ -45,4 +46,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} \ No newline at end of file +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar index ca420bac2..63d3ae2b4 100644 --- a/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar @@ -20,7 +20,8 @@ cd ${REPO} exec git config push.default simple # Prepare a branch where changes are pulled from the default branch instead of remote branch of same name -exec git checkout -b feature-branch origin/main +exec git checkout -b feature-branch +exec git branch --set-upstream-to origin/main exec git commit --allow-empty -m 'Empty Commit' exec git push origin feature-branch 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 d9b22a023..097775cbd 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 @@ -33,7 +33,8 @@ exec gh repo clone ${USER}/${FORK} cd ${FORK} # Prepare a branch where changes are pulled from the upstream default branch but pushed to fork -exec git checkout -b feature-branch upstream/main +exec git checkout -b feature-branch +exec git branch --set-upstream-to upstream/main exec git commit --allow-empty -m 'Empty Commit' exec git push origin feature-branch @@ -43,4 +44,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view ${USER}:feature-branch --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} \ No newline at end of file +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} 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 ef80cd8ba..f0bb0e6e7 100644 --- a/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar +++ b/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar @@ -27,7 +27,8 @@ exec gh repo clone ${ORG}/${FORK} cd ${FORK} # Prepare a branch where changes are pulled from the upstream default branch but pushed to fork -exec git checkout -b feature-branch upstream/main +exec git checkout -b feature-branch +exec git branch --set-upstream-to upstream/main exec git config branch.feature-branch.pushRemote origin exec git commit --allow-empty -m 'Empty Commit' exec git push 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 8bfac2837..a3d376b80 100644 --- a/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar @@ -27,7 +27,8 @@ exec gh repo clone ${ORG}/${FORK} cd ${FORK} # Prepare a branch where changes are pulled from the upstream default branch but pushed to fork -exec git checkout -b feature-branch upstream/main +exec git checkout -b feature-branch +exec git branch --set-upstream-to upstream/main exec git config remote.pushDefault origin exec git commit --allow-empty -m 'Empty Commit' exec git push 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 114f401ec..b9621ea72 100644 --- a/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-view-status-respects-simple-pushdefault.txtar @@ -18,7 +18,8 @@ cd ${REPO} exec git config push.default simple # Prepare a branch where changes are pulled from the default branch instead of remote branch of same name -exec git checkout -b feature-branch origin/main +exec git checkout -b feature-branch +exec git branch --set-upstream-to origin/main # Create the PR exec git commit --allow-empty -m 'Empty Commit' From a9dbda69135631511eebfa8173f80e93e726c7b2 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 2 Apr 2025 13:21:10 +0200 Subject: [PATCH 140/147] Rework ref usage when finding and creating PRs --- .../pr/pr-checkout-with-url-from-fork.txtar | 1 + .../pr-create-guesses-remote-from-sha.txtar | 46 ++ ...pr-create-respects-branch-pushremote.txtar | 2 +- .../pr-create-respects-push-destination.txtar | 4 +- ...r-create-respects-remote-pushdefault.txtar | 2 +- ...te-respects-user-colon-branch-syntax.txtar | 4 +- .../pr-create-without-upstream-config.txtar | 10 +- .../pr/pr-status-respects-cross-org.txtar | 46 ++ .../testdata/pr/pr-view-same-org-fork.txtar | 3 +- ...ew-status-respects-branch-pushremote.txtar | 3 +- ...w-status-respects-remote-pushdefault.txtar | 3 +- acceptance/testdata/repo/repo-fork-sync.txtar | 4 +- ...secret-require-remote-disambiguation.txtar | 4 +- git/client.go | 82 ++- git/client_test.go | 147 +++- internal/run/stub.go | 2 +- pkg/cmd/pr/create/create.go | 668 +++++++++++------- pkg/cmd/pr/create/create_test.go | 408 +++++++---- pkg/cmd/pr/shared/find_refs_resolution.go | 394 +++++++++++ .../pr/shared/find_refs_resolution_test.go | 508 +++++++++++++ pkg/cmd/pr/shared/finder.go | 208 ++---- pkg/cmd/pr/shared/finder_test.go | 666 +++++------------ pkg/cmd/pr/shared/git_cached_config_client.go | 18 + pkg/cmd/pr/status/status.go | 51 +- pkg/cmd/pr/status/status_test.go | 48 +- pkg/httpmock/registry.go | 22 +- pkg/httpmock/stub.go | 1 + pkg/option/option.go | 9 + 28 files changed, 2254 insertions(+), 1110 deletions(-) create mode 100644 acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar create mode 100644 acceptance/testdata/pr/pr-status-respects-cross-org.txtar create mode 100644 pkg/cmd/pr/shared/find_refs_resolution.go create mode 100644 pkg/cmd/pr/shared/find_refs_resolution_test.go create mode 100644 pkg/cmd/pr/shared/git_cached_config_client.go 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 9a0494f4b..637422a5a 100644 --- a/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar +++ b/acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar @@ -12,6 +12,7 @@ defer gh repo delete --yes ${ORG}/${REPO} # Create a fork exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${REPO}-fork +sleep 5 # Defer fork cleanup defer gh repo delete --yes ${ORG}/${REPO}-fork diff --git a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar new file mode 100644 index 000000000..52579b501 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar @@ -0,0 +1,46 @@ +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} +env FORK=${REPO}-fork + +# Use gh as a credential helper +exec gh auth setup-git + +# Get the current username for the fork owner +exec gh api user --jq .login +stdout2env USER + +# Create a repository with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes ${ORG}/${REPO} + +# Create a user fork of repository. This will be owned by USER. +exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} +sleep 5 + +# Defer repo cleanup of fork +defer gh repo delete --yes ${USER}/${FORK} + +# Retrieve fork repository information +exec gh repo view ${USER}/${FORK} --json id --jq '.id' +stdout2env FORK_ID + +exec gh repo clone ${USER}/${FORK} +cd ${FORK} + +# Prepare a branch to commit +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Upstream Commit' +exec git push upstream feature-branch + +# Prepare an additional commit +exec git commit --allow-empty -m 'Fork Commit' +exec git push origin feature-branch + +# Create the PR +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 + +# Check the PR is indeed created +exec gh pr view ${USER}:feature-branch --json headRefName,headRepository,baseRefName,isCrossRepository +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar index 189caaf9e..e0d0c099c 100644 --- a/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar +++ b/acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar @@ -19,12 +19,12 @@ defer gh repo delete --yes ${ORG}/${REPO} # Create a user fork of repository. This will be owned by USER. exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} +sleep 5 # Defer repo cleanup of fork defer gh repo delete --yes ${USER}/${FORK} # Retrieve fork repository information -sleep 5 exec gh repo view ${USER}/${FORK} --json id --jq '.id' stdout2env FORK_ID diff --git a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar index 142a2ec35..51708405d 100644 --- a/acceptance/testdata/pr/pr-create-respects-push-destination.txtar +++ b/acceptance/testdata/pr/pr-create-respects-push-destination.txtar @@ -19,12 +19,12 @@ defer gh repo delete --yes ${ORG}/${REPO} # Create a user fork of repository. This will be owned by USER. exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} +sleep 5 # Defer repo cleanup of fork defer gh repo delete --yes ${USER}/${FORK} # Retrieve fork repository information -sleep 5 exec gh repo view ${USER}/${FORK} --json id --jq '.id' stdout2env FORK_ID @@ -50,4 +50,4 @@ stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 # Assert that the PR was created with the correct head repository and refs exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository -stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} \ No newline at end of file +stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar index 2b4b28809..ff92f1e2d 100644 --- a/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar @@ -19,12 +19,12 @@ defer gh repo delete --yes ${ORG}/${REPO} # Create a user fork of repository. This will be owned by USER. exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} +sleep 5 # Defer repo cleanup of fork defer gh repo delete --yes ${USER}/${FORK} # Retrieve fork repository information -sleep 5 exec gh repo view ${USER}/${FORK} --json id --jq '.id' stdout2env FORK_ID 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 097775cbd..a59171d58 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 @@ -19,16 +19,16 @@ defer gh repo delete --yes ${ORG}/${REPO} # Create a user fork of repository. This will be owned by USER. exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} +sleep 5 # Defer repo cleanup of fork defer gh repo delete --yes ${USER}/${FORK} # Retrieve fork repository information -sleep 5 exec gh repo view ${USER}/${FORK} --json id --jq '.id' stdout2env FORK_ID -# Clone the repo +# Clone the fork exec gh repo clone ${USER}/${FORK} cd ${FORK} diff --git a/acceptance/testdata/pr/pr-create-without-upstream-config.txtar b/acceptance/testdata/pr/pr-create-without-upstream-config.txtar index 00f3535a7..e5a40af72 100644 --- a/acceptance/testdata/pr/pr-create-without-upstream-config.txtar +++ b/acceptance/testdata/pr/pr-create-without-upstream-config.txtar @@ -1,20 +1,22 @@ # 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 +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} + # Use gh as a credential helper exec gh auth setup-git # Create a repository with a file so it has a default branch -exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private +exec gh repo create ${ORG}/${REPO} --add-readme --private # Defer repo cleanup -defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING +defer gh repo delete --yes ${ORG}/${REPO} # Clone the repo -exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING +exec gh repo clone ${ORG}/${REPO} # Prepare a branch to PR -cd $SCRIPT_NAME-$RANDOM_STRING +cd ${REPO} exec git checkout -b feature-branch exec git commit --allow-empty -m 'Empty Commit' exec git push origin feature-branch diff --git a/acceptance/testdata/pr/pr-status-respects-cross-org.txtar b/acceptance/testdata/pr/pr-status-respects-cross-org.txtar new file mode 100644 index 000000000..4505be923 --- /dev/null +++ b/acceptance/testdata/pr/pr-status-respects-cross-org.txtar @@ -0,0 +1,46 @@ +skip 'it creates a fork owned by the user running the test' + +# Setup environment variables used for testscript +env REPO=${SCRIPT_NAME}-${RANDOM_STRING} +env FORK=${REPO}-fork + +# Use gh as a credential helper +exec gh auth setup-git + +# Get the current username for the fork owner +exec gh api user --jq .login +stdout2env USER + +# Create a repository to act as upstream with a file so it has a default branch +exec gh repo create ${ORG}/${REPO} --add-readme --private + +# Defer repo cleanup of upstream +defer gh repo delete --yes ${ORG}/${REPO} + +# Create a user fork of repository. This will be owned by USER. +exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK} +sleep 5 + +# Defer repo cleanup of fork +defer gh repo delete --yes ${USER}/${FORK} + +# Retrieve fork repository information +exec gh repo view ${USER}/${FORK} --json id --jq '.id' +stdout2env FORK_ID + +# Clone the repo +exec gh repo clone ${USER}/${FORK} +cd ${FORK} + +# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork +exec git checkout -b feature-branch +exec git commit --allow-empty -m 'Empty Commit' +exec git push -u origin feature-branch + +# Create the PR spanning upstream and fork repositories +exec gh pr create --title 'Feature Title' --body 'Feature Body' +stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1 + +# Assert that the PR was created with the correct head repository and refs +exec gh pr status +! stdout 'There is no pull request associated with' diff --git a/acceptance/testdata/pr/pr-view-same-org-fork.txtar b/acceptance/testdata/pr/pr-view-same-org-fork.txtar index ca58918a9..eed524dec 100644 --- a/acceptance/testdata/pr/pr-view-same-org-fork.txtar +++ b/acceptance/testdata/pr/pr-view-same-org-fork.txtar @@ -15,10 +15,11 @@ stdout2env REPO_ID # Create a fork in the same org exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK} +sleep 5 # Defer repo cleanup of fork defer gh repo delete --yes ${ORG}/${FORK} -sleep 1 + exec gh repo view ${ORG}/${FORK} --json id --jq '.id' stdout2env FORK_ID 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 f0bb0e6e7..4e1e5e64a 100644 --- a/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar +++ b/acceptance/testdata/pr/pr-view-status-respects-branch-pushremote.txtar @@ -15,10 +15,11 @@ stdout2env REPO_ID # Create a user fork of repository as opposed to private organization fork exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK} +sleep 5 # Defer repo cleanup of fork defer gh repo delete --yes ${ORG}/${FORK} -sleep 5 + exec gh repo view ${ORG}/${FORK} --json id --jq '.id' stdout2env FORK_ID 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 a3d376b80..6c0743a6f 100644 --- a/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar +++ b/acceptance/testdata/pr/pr-view-status-respects-remote-pushdefault.txtar @@ -15,10 +15,11 @@ stdout2env REPO_ID # Create a user fork of repository as opposed to private organization fork exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK} +sleep 5 # Defer repo cleanup of fork defer gh repo delete --yes ${ORG}/${FORK} -sleep 5 + exec gh repo view ${ORG}/${FORK} --json id --jq '.id' stdout2env FORK_ID diff --git a/acceptance/testdata/repo/repo-fork-sync.txtar b/acceptance/testdata/repo/repo-fork-sync.txtar index 6ed7b94e1..04c4c5845 100644 --- a/acceptance/testdata/repo/repo-fork-sync.txtar +++ b/acceptance/testdata/repo/repo-fork-sync.txtar @@ -9,13 +9,11 @@ defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING # Fork and clone the repo exec gh repo fork $ORG/$SCRIPT_NAME-$RANDOM_STRING --org $ORG --fork-name $SCRIPT_NAME-$RANDOM_STRING-fork --clone +sleep 5 # Defer fork cleanup defer gh repo delete $ORG/$SCRIPT_NAME-$RANDOM_STRING-fork --yes -# Sleep so that the BE has time to sync -sleep 5 - # Check that the repo was forked exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING-fork --json='isFork' --jq='.isFork' stdout 'true' diff --git a/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar index 02dec06a0..f3fa4a47a 100644 --- a/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar +++ b/acceptance/testdata/secret/secret-require-remote-disambiguation.txtar @@ -12,13 +12,11 @@ defer gh repo delete --yes ${ORG}/${REPO} # Create a fork exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${REPO}-fork +sleep 5 # Defer fork cleanup defer gh repo delete --yes ${ORG}/${REPO}-fork -# Sleep to allow the fork to be created before cloning -sleep 2 - # Clone and move into the fork repo exec gh repo clone ${ORG}/${REPO}-fork cd ${REPO}-fork diff --git a/git/client.go b/git/client.go index 11a2e2e20..fe2819cf0 100644 --- a/git/client.go +++ b/git/client.go @@ -381,7 +381,6 @@ func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, // Downstream consumers of ReadBranchConfig should consider the behavior they desire if this errors, // as an empty config is not necessarily breaking. func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (BranchConfig, error) { - prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch)) args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge|pushremote|%s)$", prefix, MergeBaseConfig)} cmd, err := c.Command(ctx, args...) @@ -441,18 +440,50 @@ func (c *Client) SetBranchConfig(ctx context.Context, branch, name, value string return err } +// PushDefault defines the action git push should take if no refspec is given. +// See: https://git-scm.com/docs/git-config#Documentation/git-config.txt-pushdefault +type PushDefault string + +const ( + PushDefaultNothing PushDefault = "nothing" + PushDefaultCurrent PushDefault = "current" + PushDefaultUpstream PushDefault = "upstream" + PushDefaultTracking PushDefault = "tracking" + PushDefaultSimple PushDefault = "simple" + PushDefaultMatching PushDefault = "matching" +) + +func ParsePushDefault(s string) (PushDefault, error) { + validPushDefaults := map[string]struct{}{ + string(PushDefaultNothing): {}, + string(PushDefaultCurrent): {}, + string(PushDefaultUpstream): {}, + string(PushDefaultTracking): {}, + string(PushDefaultSimple): {}, + string(PushDefaultMatching): {}, + } + + if _, ok := validPushDefaults[s]; ok { + return PushDefault(s), nil + } + + return "", fmt.Errorf("unknown push.default value: %s", s) +} + // PushDefault returns the value of push.default in the config. If the value // is not set, it returns "simple" (the default git value). See // https://git-scm.com/docs/git-config#Documentation/git-config.txt-pushdefault -func (c *Client) PushDefault(ctx context.Context) (string, error) { +func (c *Client) PushDefault(ctx context.Context) (PushDefault, error) { pushDefault, err := c.Config(ctx, "push.default") if err == nil { - return pushDefault, nil + return ParsePushDefault(pushDefault) } + // If there is an error that the config key is not set, return the default value + // that git uses since 2.0. var gitError *GitError if ok := errors.As(err, &gitError); ok && gitError.ExitCode == 1 { - return "simple", nil + return PushDefaultSimple, nil } return "", err } @@ -473,13 +504,48 @@ func (c *Client) RemotePushDefault(ctx context.Context) (string, error) { return "", err } -// ParsePushRevision gets the value of the @{push} revision syntax +// RemoteTrackingRef is the structured form of the string "refs/remotes//". +// For example, the @{push} revision syntax could report "refs/remotes/origin/main" which would +// be parsed into RemoteTrackingRef{Remote: "origin", Branch: "main"}. +type RemoteTrackingRef struct { + Remote string + Branch string +} + +func (r RemoteTrackingRef) String() string { + return fmt.Sprintf("refs/remotes/%s/%s", r.Remote, r.Branch) +} + +// ParseRemoteTrackingRef parses a string of the form "refs/remotes//" into +// a RemoteTrackingBranch struct. If the string does not match this format, an error is returned. +func ParseRemoteTrackingRef(s string) (RemoteTrackingRef, error) { + parts := strings.Split(s, "/") + if len(parts) != 4 || parts[0] != "refs" || parts[1] != "remotes" { + return RemoteTrackingRef{}, fmt.Errorf("remote tracking branch must have format refs/remotes// but was: %s", s) + } + + return RemoteTrackingRef{ + Remote: parts[2], + Branch: parts[3], + }, nil +} + +// PushRevision gets the value of the @{push} revision syntax // An error here doesn't necessarily mean something is broken, but may mean that the @{push} // revision syntax couldn't be resolved, such as in non-centralized workflows with // push.default = simple. Downstream consumers should consider how to handle this error. -func (c *Client) ParsePushRevision(ctx context.Context, branch string) (string, error) { - revParseOut, err := c.revParse(ctx, "--abbrev-ref", branch+"@{push}") - return firstLine(revParseOut), err +func (c *Client) PushRevision(ctx context.Context, branch string) (RemoteTrackingRef, error) { + revParseOut, err := c.revParse(ctx, "--symbolic-full-name", branch+"@{push}") + if err != nil { + return RemoteTrackingRef{}, err + } + + ref, err := ParseRemoteTrackingRef(firstLine(revParseOut)) + if err != nil { + return RemoteTrackingRef{}, fmt.Errorf("could not parse push revision: %v", err) + } + + return ref, nil } func (c *Client) DeleteLocalTag(ctx context.Context, tag string) error { diff --git a/git/client_test.go b/git/client_test.go index 9fa076199..3d7560228 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -952,7 +952,7 @@ func TestClientPushDefault(t *testing.T) { tests := []struct { name string commandResult commandResult - wantPushDefault string + wantPushDefault PushDefault wantError *GitError }{ { @@ -961,7 +961,7 @@ func TestClientPushDefault(t *testing.T) { ExitStatus: 1, Stderr: "error: key does not contain a section: remote.pushDefault", }, - wantPushDefault: "simple", + wantPushDefault: PushDefaultSimple, wantError: nil, }, { @@ -970,7 +970,7 @@ func TestClientPushDefault(t *testing.T) { ExitStatus: 0, Stdout: "current", }, - wantPushDefault: "current", + wantPushDefault: PushDefaultCurrent, wantError: nil, }, { @@ -1077,17 +1077,17 @@ func TestClientParsePushRevision(t *testing.T) { name string branch string commandResult commandResult - wantParsedPushRevision string - wantError *GitError + wantParsedPushRevision RemoteTrackingRef + wantError error }{ { - name: "@{push} resolves to origin/branchName", + name: "@{push} resolves to refs/remotes/origin/branchName", branch: "branchName", commandResult: commandResult{ ExitStatus: 0, - Stdout: "origin/branchName", + Stdout: "refs/remotes/origin/branchName", }, - wantParsedPushRevision: "origin/branchName", + wantParsedPushRevision: RemoteTrackingRef{Remote: "origin", Branch: "branchName"}, }, { name: "@{push} doesn't resolve", @@ -1095,16 +1095,25 @@ func TestClientParsePushRevision(t *testing.T) { ExitStatus: 128, Stderr: "fatal: git error", }, - wantParsedPushRevision: "", + wantParsedPushRevision: RemoteTrackingRef{}, wantError: &GitError{ ExitCode: 128, Stderr: "fatal: git error", }, }, + { + name: "@{push} resolves to something surprising", + commandResult: commandResult{ + ExitStatus: 0, + Stdout: "not/a/valid/remote/ref", + }, + wantParsedPushRevision: RemoteTrackingRef{}, + wantError: fmt.Errorf("could not parse push revision: remote tracking branch must have format refs/remotes// but was: not/a/valid/remote/ref"), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cmd := fmt.Sprintf("path/to/git rev-parse --abbrev-ref %s@{push}", tt.branch) + cmd := fmt.Sprintf("path/to/git rev-parse --symbolic-full-name %s@{push}", tt.branch) cmdCtx := createMockedCommandContext(t, mockedCommands{ args(cmd): tt.commandResult, }) @@ -1112,20 +1121,91 @@ func TestClientParsePushRevision(t *testing.T) { GitPath: "path/to/git", commandContext: cmdCtx, } - pushDefault, err := client.ParsePushRevision(context.Background(), tt.branch) + trackingRef, err := client.PushRevision(context.Background(), tt.branch) if tt.wantError != nil { - var gitError *GitError - require.ErrorAs(t, err, &gitError) - assert.Equal(t, tt.wantError.ExitCode, gitError.ExitCode) - assert.Equal(t, tt.wantError.Stderr, gitError.Stderr) + var wantErrorAsGit *GitError + if errors.As(err, &wantErrorAsGit) { + var gitError *GitError + require.ErrorAs(t, err, &gitError) + assert.Equal(t, wantErrorAsGit.ExitCode, gitError.ExitCode) + assert.Equal(t, wantErrorAsGit.Stderr, gitError.Stderr) + } else { + assert.Equal(t, err, tt.wantError) + } } else { require.NoError(t, err) } - assert.Equal(t, tt.wantParsedPushRevision, pushDefault) + assert.Equal(t, tt.wantParsedPushRevision, trackingRef) }) } } +func TestRemoteTrackingRef(t *testing.T) { + t.Run("parsing", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + remoteTrackingRef string + wantRemoteTrackingRef RemoteTrackingRef + wantError error + }{ + { + name: "valid remote tracking ref", + remoteTrackingRef: "refs/remotes/origin/branchName", + wantRemoteTrackingRef: RemoteTrackingRef{ + Remote: "origin", + Branch: "branchName", + }, + }, + { + name: "incorrect parts", + remoteTrackingRef: "refs/remotes/origin", + wantRemoteTrackingRef: RemoteTrackingRef{}, + wantError: fmt.Errorf("remote tracking branch must have format refs/remotes// but was: refs/remotes/origin"), + }, + { + name: "incorrect prefix type", + remoteTrackingRef: "invalid/remotes/origin/branchName", + wantRemoteTrackingRef: RemoteTrackingRef{}, + wantError: fmt.Errorf("remote tracking branch must have format refs/remotes// but was: invalid/remotes/origin/branchName"), + }, + { + name: "incorrect ref type", + remoteTrackingRef: "refs/invalid/origin/branchName", + wantRemoteTrackingRef: RemoteTrackingRef{}, + wantError: fmt.Errorf("remote tracking branch must have format refs/remotes// but was: refs/invalid/origin/branchName"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + trackingRef, err := ParseRemoteTrackingRef(tt.remoteTrackingRef) + if tt.wantError != nil { + require.Equal(t, tt.wantError, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantRemoteTrackingRef, trackingRef) + }) + } + }) + + t.Run("stringifying", func(t *testing.T) { + t.Parallel() + + remoteTrackingRef := RemoteTrackingRef{ + Remote: "origin", + Branch: "branchName", + } + + require.Equal(t, "refs/remotes/origin/branchName", remoteTrackingRef.String()) + }) +} + func TestClientDeleteLocalTag(t *testing.T) { tests := []struct { name string @@ -1992,6 +2072,41 @@ func TestCredentialPatternFromHost(t *testing.T) { } } +func TestPushDefault(t *testing.T) { + t.Run("it parses valid values correctly", func(t *testing.T) { + t.Parallel() + + tests := []struct { + value string + expectedPushDefault PushDefault + }{ + {"nothing", PushDefaultNothing}, + {"current", PushDefaultCurrent}, + {"upstream", PushDefaultUpstream}, + {"tracking", PushDefaultTracking}, + {"simple", PushDefaultSimple}, + {"matching", PushDefaultMatching}, + } + + for _, test := range tests { + t.Run(test.value, func(t *testing.T) { + t.Parallel() + + pushDefault, err := ParsePushDefault(test.value) + require.NoError(t, err) + assert.Equal(t, test.expectedPushDefault, pushDefault) + }) + } + }) + + t.Run("it returns an error for invalid values", func(t *testing.T) { + t.Parallel() + + _, err := ParsePushDefault("invalid") + require.Error(t, err) + }) +} + func createCommandContext(t *testing.T, exitStatus int, stdout, stderr string) (*exec.Cmd, commandCtx) { cmd := exec.CommandContext(context.Background(), os.Args[0], "-test.run=TestHelperProcess", "--") cmd.Env = []string{ diff --git a/internal/run/stub.go b/internal/run/stub.go index 5cd3c6de5..507fd61d6 100644 --- a/internal/run/stub.go +++ b/internal/run/stub.go @@ -46,7 +46,7 @@ func Stub() (*CommandStubber, func(T)) { return } t.Helper() - t.Errorf("unmatched stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", ")) + t.Errorf("unmatched exec stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", ")) } } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 8ea0b48db..eda7a3ce7 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -25,6 +25,7 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/markdown" + o "github.com/cli/cli/v2/pkg/option" "github.com/spf13/cobra" ) @@ -72,16 +73,107 @@ type CreateOptions struct { DryRun bool } +// creationRefs is an interface that provides the necessary information for creating a pull request in the API. +// Upcasting to concrete implementations can provide further context on other operations (forking and pushing). +type creationRefs interface { + // QualifiedHeadRef returns a stringified form of the head ref, varying depending + // on whether the head ref is in the same repository as the base ref. If they are + // the same repository, we return the branch name only. If they are different repositories, + // we return the owner and branch name in the form :. + QualifiedHeadRef() string + // UnqualifiedHeadRef returns a head ref in the form of the branch name only. + UnqualifiedHeadRef() string + //BaseRef returns the base branch name. + BaseRef() string + + // While the only thing really required from an api.Repository is the repository ID, changing that + // would require changing the API function signatures, and the refactor that introduced this refs + // type is already large enough. + BaseRepo() *api.Repository +} + +type baseRefs struct { + baseRepo *api.Repository + baseBranchName string +} + +func (r baseRefs) BaseRef() string { + return r.baseBranchName +} + +func (r baseRefs) BaseRepo() *api.Repository { + return r.baseRepo +} + +// skipPushRefs indicate to handlePush that no pushing is required. +type skipPushRefs struct { + baseRefs + + qualifiedHeadRef shared.QualifiedHeadRef +} + +func (r skipPushRefs) QualifiedHeadRef() string { + return r.qualifiedHeadRef.String() +} + +func (r skipPushRefs) UnqualifiedHeadRef() string { + return r.qualifiedHeadRef.BranchName() +} + +// pushableRefs indicate to handlePush that pushing is required, +// and provide further information (HeadRepo) on where that push +// should go. +type pushableRefs struct { + baseRefs + + headRepo ghrepo.Interface + headBranchName string +} + +func (r pushableRefs) QualifiedHeadRef() string { + if ghrepo.IsSame(r.headRepo, r.baseRepo) { + return r.headBranchName + } + return fmt.Sprintf("%s:%s", r.headRepo.RepoOwner(), r.headBranchName) +} + +func (r pushableRefs) UnqualifiedHeadRef() string { + return r.headBranchName +} + +func (r pushableRefs) HeadRepo() ghrepo.Interface { + return r.headRepo +} + +// forkableRefs indicate to handlePush that forking is required before +// pushing. The expectation is that after forking, this is converted to +// pushableRefs. We could go very OOP and have a Fork method on this +// struct that returns a pushableRefs but then we'd need to embed an API client +// and it just seems nice that it is a simple bag of data. +type forkableRefs struct { + baseRefs + + qualifiedHeadRef shared.QualifiedHeadRef +} + +func (r forkableRefs) QualifiedHeadRef() string { + return r.qualifiedHeadRef.String() +} + +func (r forkableRefs) UnqualifiedHeadRef() string { + return r.qualifiedHeadRef.BranchName() +} + +// CreateContext stores contextual data about the creation process and is for building up enough +// data to create a pull request. type CreateContext struct { - // This struct stores contextual data about the creation process and is for building up enough - // data to create a pull request - RepoContext *ghContext.ResolvedRemotes - PrRefs shared.PullRequestRefs + ResolvedRemotes *ghContext.ResolvedRemotes + PRRefs creationRefs + // BaseTrackingBranch is perhaps a slightly leaky abstraction in the presence + // of PRRefs, but a huge amount of refactoring was done to introduce that struct, + // and this is a small price to pay for the convenience of not having to do a lot + // more design. BaseTrackingBranch string - BaseBranch string // Currently not supported by shared.PullRequestRefs struct - HeadRemote *ghContext.Remote - isPushEnabled bool - forkHeadRepo bool Client *api.Client GitClient *git.Client } @@ -312,8 +404,8 @@ func createRun(opts *CreateOptions) error { } existingPR, _, err := opts.Finder.Find(shared.FindOptions{ - Selector: ctx.PrRefs.GetPRHeadLabel(), - BaseBranch: ctx.BaseBranch, + Selector: ctx.PRRefs.QualifiedHeadRef(), + BaseBranch: ctx.PRRefs.BaseRef(), States: []string{"OPEN"}, Fields: []string{"url"}, }) @@ -323,7 +415,7 @@ func createRun(opts *CreateOptions) error { } if err == nil { return fmt.Errorf("a pull request for branch %q into branch %q already exists:\n%s", - ctx.PrRefs.GetPRHeadLabel(), ctx.BaseBranch, existingPR.URL) + ctx.PRRefs.QualifiedHeadRef(), ctx.PRRefs.BaseRef(), existingPR.URL) } message := "\nCreating pull request for %s into %s in %s\n\n" @@ -338,9 +430,9 @@ func createRun(opts *CreateOptions) error { if opts.IO.CanPrompt() { fmt.Fprintf(opts.IO.ErrOut, message, - cs.Cyan(ctx.PrRefs.GetPRHeadLabel()), - cs.Cyan(ctx.BaseBranch), - ghrepo.FullName(ctx.PrRefs.BaseRepo)) + cs.Cyan(ctx.PRRefs.QualifiedHeadRef()), + cs.Cyan(ctx.PRRefs.BaseRef()), + ghrepo.FullName(ctx.PRRefs.BaseRepo())) } if !opts.EditorMode && (opts.FillVerbose || opts.Autofill || opts.FillFirst || (opts.TitleProvided && opts.BodyProvided)) { @@ -363,7 +455,7 @@ func createRun(opts *CreateOptions) error { action = shared.SubmitDraftAction } - tpl := shared.NewTemplateManager(client.HTTP(), ctx.PrRefs.BaseRepo, opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true) + tpl := shared.NewTemplateManager(client.HTTP(), ctx.PRRefs.BaseRepo(), opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true) if opts.EditorMode { if opts.Template != "" { @@ -431,7 +523,7 @@ func createRun(opts *CreateOptions) error { } allowPreview := !state.HasMetadata() && shared.ValidURL(openURL) && !opts.DryRun - allowMetadata := ctx.PrRefs.BaseRepo.(*api.Repository).ViewerCanTriage() + allowMetadata := ctx.PRRefs.BaseRepo().ViewerCanTriage() action, err = shared.ConfirmPRSubmission(opts.Prompter, allowPreview, allowMetadata, state.Draft) if err != nil { return fmt.Errorf("unable to confirm: %w", err) @@ -441,10 +533,10 @@ func createRun(opts *CreateOptions) error { fetcher := &shared.MetadataFetcher{ IO: opts.IO, APIClient: client, - Repo: ctx.PrRefs.BaseRepo, + Repo: ctx.PRRefs.BaseRepo(), State: state, } - err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PrRefs.BaseRepo, fetcher, state) + err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state) if err != nil { return err } @@ -487,11 +579,7 @@ func createRun(opts *CreateOptions) error { var regexPattern = regexp.MustCompile(`(?m)^`) func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, useFirstCommit bool, addBody bool) error { - baseRef := ctx.BaseTrackingBranch - headRef := ctx.PrRefs.BranchName - gitClient := ctx.GitClient - - commits, err := gitClient.Commits(context.Background(), baseRef, headRef) + commits, err := ctx.GitClient.Commits(context.Background(), ctx.BaseTrackingBranch, ctx.PRRefs.UnqualifiedHeadRef()) if err != nil { return err } @@ -500,7 +588,7 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, u state.Title = commits[len(commits)-1].Title state.Body = commits[len(commits)-1].Body } else { - state.Title = humanize(headRef) + state.Title = humanize(ctx.PRRefs.UnqualifiedHeadRef()) var body strings.Builder for i := len(commits) - 1; i >= 0; i-- { fmt.Fprintf(&body, "- **%s**\n", commits[i].Title) @@ -526,7 +614,7 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata milestoneTitles = []string{opts.Milestone} } - meReplacer := shared.NewMeReplacer(ctx.Client, ctx.PrRefs.BaseRepo.RepoHost()) + meReplacer := shared.NewMeReplacer(ctx.Client, ctx.PRRefs.BaseRepo().RepoHost()) assignees, err := meReplacer.ReplaceSlice(opts.Assignees) if err != nil { return nil, err @@ -553,7 +641,6 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata } func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { - ctx := context.Background() httpClient, err := opts.HttpClient() if err != nil { return nil, err @@ -565,25 +652,19 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { return nil, err } - gitClient := opts.GitClient - if ucc, err := gitClient.UncommittedChangeCount(ctx); err == nil && ucc > 0 { - fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", text.Pluralize(ucc, "uncommitted change")) - } - - // Resolve base repo - repoContext, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) + resolvedRemotes, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride) if err != nil { return nil, err } - var targetBaseRepo *api.Repository - if br, err := repoContext.BaseRepo(opts.IO); err == nil { + var baseRepo *api.Repository + if br, err := resolvedRemotes.BaseRepo(opts.IO); err == nil { if r, ok := br.(*api.Repository); ok { - targetBaseRepo = r + baseRepo = r } else { // TODO: if RepoNetwork is going to be requested anyway in `repoContext.HeadRepos()`, // consider piggybacking on that result instead of performing a separate lookup - targetBaseRepo, err = api.GitHubRepo(client, br) + baseRepo, err = api.GitHubRepo(client, br) if err != nil { return nil, err } @@ -592,181 +673,284 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) { return nil, err } - // Resolve target head branch name from either - // --head or the current branch. - var targetHeadBranch string - var targetHeadRepoOwner string + // This closure provides an easy way to instantiate a CreateContext with everything other than + // the refs. This probably indicates that CreateContext could do with some rework, but the refactor + // to introduce PRRefs is already large enough. + var newCreateContext = func(refs creationRefs) *CreateContext { + baseTrackingBranch := refs.BaseRef() - promptForHeadRepo := true + // The baseTrackingBranch is used later for a command like: + // `git commit upstream/main feature` in order to create a PR message showing the commits + // between these two refs. I'm not really sure what is expected to happen if we don't have a remote, + // which seems like it would be possible with a command `gh pr create --repo owner/repo-that-is-not-a-remote`. + // In that case, we might just have a mess? In any case, this is what the old code did, so I don't want to change + // it as part of an already large refactor. + baseRemote, _ := resolvedRemotes.RemoteForRepo(baseRepo) + if baseRemote != nil { + baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseTrackingBranch) + } + return &CreateContext{ + ResolvedRemotes: resolvedRemotes, + Client: client, + GitClient: opts.GitClient, + PRRefs: refs, + BaseTrackingBranch: baseTrackingBranch, + } + } + + // If the user provided a head branch we're going to use that without any interrogation + // of git. The value can take the form of or :. In the former case, the + // PR base and head repos are the same. In the latter case we don't know the head repo + // (though we could look it up in the API) but fortunately we don't need to because the API + // will resolve this for us when we create the pull request. This is possible because + // users can only have a single fork in their namespace, and organizations don't work at all with this ref format. + // + // Note that providing the head branch in this way indicates that we shouldn't push the branch, + // and we indicate that via the returned type as well. if opts.HeadBranch != "" { - promptForHeadRepo = false - targetHeadBranch = opts.HeadBranch - // If the --head provided contains a colon, that means - // this is : syntax. - if idx := strings.IndexRune(opts.HeadBranch, ':'); idx >= 0 { - targetHeadRepoOwner = opts.HeadBranch[:idx] - targetHeadBranch = opts.HeadBranch[idx+1:] - } - } else { - // Use the current branch as the target local head branch when - // --head is not provided. - targetHeadBranch, err = opts.Branch() - if err != nil { - return nil, fmt.Errorf("could not determine the current branch: %w", err) - } - } - - targetHeadBranchConfig, err := gitClient.ReadBranchConfig(ctx, targetHeadBranch) - if err != nil { - return nil, err - } - - // See if we can determine if this branch has been push previously with - // Git configurations and @{push} revision syntax. - remotePushDefault, err := opts.GitClient.RemotePushDefault(ctx) - if err != nil { - return nil, err - } - // Suppressing these errors as we have other means of computing the PullRequestRefs when these fail. - parsedPushRevision, _ := gitClient.ParsePushRevision(ctx, targetHeadBranch) - pushDefault, err := gitClient.PushDefault(ctx) - if err != nil { - return nil, err - } - - prRefs, err := shared.ParsePRRefs(targetHeadBranch, targetHeadBranchConfig, parsedPushRevision, pushDefault, remotePushDefault, targetBaseRepo, remotes) - if err != nil { - return nil, err - } - - // If the --head provided contains : syntax, we need to use - // the provided owner instead of the owner of the base repository. - if targetHeadRepoOwner != "" { - prRefs.HeadRepo = ghrepo.New(targetHeadRepoOwner, prRefs.HeadRepo.RepoName()) - } - - var headRemote *ghContext.Remote - - // We received the head repository and branch from ParsePRRefs, or inferred - // it from --head input, but we need to check if it's up-to-date with - // our local branch state. - // If it is, we can use it as the head repo for the PR - // and avoid prompting the user. - // Errors raised here should not cause command to fail, - // prompt user for head repo if an error is raised or no remote found. - if prRefs.HasHead() { - // Check if the head branch is up-to-date with the local branch - headRemote, err := remotes.FindByRepo(prRefs.HeadRepo.RepoOwner(), prRefs.HeadRepo.RepoName()) - if headRemote != nil && err == nil { - headRefName := fmt.Sprintf("refs/remotes/%s/%s", headRemote, prRefs.BranchName) - refsForLookup := []string{"HEAD", headRefName} - resolvedRefs, err := gitClient.ShowRefs(ctx, refsForLookup) - - // If there is more than one resolved ref, then remote head ref was resolved. - if err == nil && len(resolvedRefs) > 1 { - headRef := resolvedRefs[0] - for _, r := range resolvedRefs[1:] { - // If the head ref is the same as the remote head ref, - // then the remote head is current and we can use it. - if r.Hash == headRef.Hash { - promptForHeadRepo = false - break - } - } - } - } - } - - var forkHeadRepo bool - var isPushEnabled bool - - if promptForHeadRepo && opts.IO.CanPrompt() { - isPushEnabled = true - // Since we could not determine a head ref, prompt the user for the head repository to push - // using a list of repositories obtained from the API - pushableRepos, err := repoContext.HeadRepos() + qualifiedHeadRef, err := shared.ParseQualifiedHeadRef(opts.HeadBranch) if err != nil { return nil, err } - if len(pushableRepos) == 0 { - pushableRepos, err = api.RepoFindForks(client, prRefs.BaseRepo, 3) - if err != nil { - return nil, err - } - } - - currentLogin, err := api.CurrentLoginName(client, prRefs.BaseRepo.RepoHost()) + branchConfig, err := opts.GitClient.ReadBranchConfig(context.Background(), qualifiedHeadRef.BranchName()) if err != nil { return nil, err } - hasOwnFork := false - var pushOptions []string - for _, r := range pushableRepos { - pushOptions = append(pushOptions, ghrepo.FullName(r)) - if r.RepoOwner() == currentLogin { - hasOwnFork = true - } + baseBranch := opts.BaseBranch + if baseBranch == "" { + baseBranch = branchConfig.MergeBase + } + if baseBranch == "" { + baseBranch = baseRepo.DefaultBranchRef.Name } - if !hasOwnFork { - pushOptions = append(pushOptions, "Create a fork of "+ghrepo.FullName(prRefs.BaseRepo)) - } - pushOptions = append(pushOptions, "Skip pushing the branch") - pushOptions = append(pushOptions, "Cancel") - - selectedOption, err := opts.Prompter.Select(fmt.Sprintf("Where should we push the '%s' branch?", prRefs.BranchName), "", pushOptions) - if err != nil { - return nil, err - } - - if selectedOption < len(pushableRepos) { - prRefs.HeadRepo = pushableRepos[selectedOption] - } else if pushOptions[selectedOption] == "Skip pushing the branch" { - isPushEnabled = false - } else if pushOptions[selectedOption] == "Cancel" { - return nil, cmdutil.CancelError - } else { - // "Create a fork of ..." - forkHeadRepo = true - prRefs.HeadRepo = ghrepo.New(currentLogin, prRefs.HeadRepo.RepoName()) - } + return newCreateContext(skipPushRefs{ + qualifiedHeadRef: qualifiedHeadRef, + baseRefs: baseRefs{ + baseRepo: baseRepo, + baseBranchName: baseBranch, + }, + }), nil } - if prRefs.HeadRepo == nil && isPushEnabled && !opts.IO.CanPrompt() { - fmt.Fprintf(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag") - return nil, cmdutil.SilentError + if ucc, err := opts.GitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 { + fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", text.Pluralize(ucc, "uncommitted change")) + } + + // If the user didn't provide a head branch then we're gettin' real. We're going to interrogate git + // and try to create refs that are pushable. + currentBranch, err := opts.Branch() + if err != nil { + return nil, fmt.Errorf("could not determine the current branch: %w", err) + } + + branchConfig, err := opts.GitClient.ReadBranchConfig(context.Background(), currentBranch) + if err != nil { + return nil, err } baseBranch := opts.BaseBranch if baseBranch == "" { - baseBranch = targetHeadBranchConfig.MergeBase + baseBranch = branchConfig.MergeBase } if baseBranch == "" { - baseBranch = targetBaseRepo.DefaultBranchRef.Name - } - if prRefs.BranchName == baseBranch && prRefs.HeadRepo != nil && ghrepo.IsSame(prRefs.BaseRepo, prRefs.HeadRepo) { - return nil, fmt.Errorf("must be on a branch named differently than %q", baseBranch) + baseBranch = baseRepo.DefaultBranchRef.Name } - baseTrackingBranch := baseBranch - if baseRemote, err := remotes.FindByRepo(prRefs.BaseRepo.RepoOwner(), prRefs.BaseRepo.RepoName()); err == nil { - baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch) + // First we check with the git information we have to see if we can figure out the default + // head repo and remote branch name. + defaultPRHead, err := shared.TryDetermineDefaultPRHead( + // We requested the branch config already, so let's cache that + shared.CachedBranchConfigGitConfigClient{ + CachedBranchConfig: branchConfig, + GitConfigClient: opts.GitClient, + }, + shared.NewRemoteToRepoResolver(opts.Remotes), + currentBranch, + ) + if err != nil { + return nil, err } - return &CreateContext{ - PrRefs: prRefs, - BaseBranch: baseBranch, // Currently not supported by shared.PullRequestRefs struct - BaseTrackingBranch: baseTrackingBranch, - HeadRemote: headRemote, - isPushEnabled: isPushEnabled, - forkHeadRepo: forkHeadRepo, - RepoContext: repoContext, - Client: client, - GitClient: gitClient, - }, nil + // The baseRefs are always going to be the same from now on. If I could make this immutable I would! + baseRefs := baseRefs{ + baseRepo: baseRepo, + baseBranchName: baseBranch, + } + + // If we were able to determine a head repo, then let's check that the remote tracking ref matches the SHA of + // HEAD. If it does, then we don't need to push, otherwise we'll need to ask the user to tell us where to push. + if headRepo, present := defaultPRHead.Repo.Value(); present { + // We may not find a remote because the git branch config may have a URL rather than a remote name. + // Ideally, we would return a sentinel error from RemoteForRepo that we could compare to, but the + // refactor that introduced this code was already large enough. + headRemote, _ := resolvedRemotes.RemoteForRepo(headRepo) + if headRemote != nil { + resolvedRefs, _ := opts.GitClient.ShowRefs( + context.Background(), + []string{ + "HEAD", + fmt.Sprintf("refs/remotes/%s/%s", headRemote.Name, defaultPRHead.BranchName), + }, + ) + + // Two refs returned means we can compare HEAD to the remote tracking branch. + // If we had a matching ref, then we can skip pushing. + refsMatch := len(resolvedRefs) == 2 && resolvedRefs[0].Hash == resolvedRefs[1].Hash + if refsMatch { + qualifiedHeadRef := shared.NewQualifiedHeadRefWithoutOwner(defaultPRHead.BranchName) + if headRepo.RepoOwner() != baseRepo.RepoOwner() { + qualifiedHeadRef = shared.NewQualifiedHeadRef(headRepo.RepoOwner(), defaultPRHead.BranchName) + } + + return newCreateContext(skipPushRefs{ + qualifiedHeadRef: qualifiedHeadRef, + baseRefs: baseRefs, + }), nil + } + } + } + + // If we didn't determine that the git indicated repo had the correct ref, we'll take a look at the other + // remotes and see whether any of them have the same SHA as HEAD. Now, at this point, you might be asking yourself: + // "Why didn't we collect all the SHAs with a single ShowRefs command above, for use in both cases?" + // ... + // That's because the code below has a bug that I've ported from the old code, in order to preserve the existing + // behaviour, and to limit the scope of an already large refactor. The intention of the original code was to loop + // over all the returned refs. However, as it turns out, our implementation of ShowRefs doesn't do that correctly. + // Since it provides the --verify flag, git will return the SHAs for refs up until it hits a ref that doesn't exist, + // at which point it bails out. + // + // Imagine you have a remotes "upstream" and "origin", and you have pushed your branch "feature" to "origin". Since + // the order of remotes is always guaranteed "upstream", "github", "origin", and then everything else unstably sorted, + // we will never get a SHA for origin, as refs/remotes/upstream/feature doesn't exist. + // + // Furthermore, when you really think about it, this code is a bit eager. What happens if you have the same SHA on + // remotes "origin" and "colleague", this will always offer origin. If it were "colleague-a" and "colleague-b", no + // order would be guaranteed between different invocations of pr create, because the order of remotes after "origin" + // is unstable sorted. + // + // All that said, this has been the behaviour for a long, long time, and I do not want to make other behavioural changes + // in what is mostly a refactor. + refsToLookup := []string{"HEAD"} + for _, remote := range remotes { + refsToLookup = append(refsToLookup, fmt.Sprintf("refs/remotes/%s/%s", remote.Name, currentBranch)) + } + + // Ignoring the error in this case is allowed because we may get refs and an error (see: --verify flag above). + // Ideally there would be a typed error to allow us to distinguish between an execution error and some refs + // not existing. However, this is too much to take on in an already large refactor. + refs, _ := opts.GitClient.ShowRefs(context.Background(), refsToLookup) + if len(refs) > 1 { + headRef := refs[0] + var firstMatchingRef o.Option[git.RemoteTrackingRef] + // Loop over all the refs, trying to find one that matches the SHA of HEAD. + for _, r := range refs[1:] { + if r.Hash == headRef.Hash { + remoteTrackingRef, err := git.ParseRemoteTrackingRef(r.Name) + if err != nil { + return nil, err + } + + firstMatchingRef = o.Some(remoteTrackingRef) + break + } + } + + // If we found a matching ref, then we don't need to push. + if ref, present := firstMatchingRef.Value(); present { + remote, err := remotes.FindByName(ref.Remote) + if err != nil { + return nil, err + } + + qualifiedHeadRef := shared.NewQualifiedHeadRefWithoutOwner(ref.Branch) + if baseRepo.RepoOwner() != remote.RepoOwner() { + qualifiedHeadRef = shared.NewQualifiedHeadRef(remote.RepoOwner(), ref.Branch) + } + + return newCreateContext(skipPushRefs{ + qualifiedHeadRef: qualifiedHeadRef, + baseRefs: baseRefs, + }), nil + } + } + + // If we haven't got a repo by now, and we can't prompt then it's game over. + if !opts.IO.CanPrompt() { + fmt.Fprintln(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag") + return nil, cmdutil.SilentError + } + + // Otherwise, hooray, prompting! + + // First, we're going to look at our remotes and decide whether there are any repos we can push to. + pushableRepos, err := resolvedRemotes.HeadRepos() + if err != nil { + return nil, err + } + + // If we couldn't find any pushable repos, then find forks of the base repo. + if len(pushableRepos) == 0 { + pushableRepos, err = api.RepoFindForks(client, baseRepo, 3) + if err != nil { + return nil, err + } + } + + currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost()) + if err != nil { + return nil, err + } + + hasOwnFork := false + var pushOptions []string + for _, r := range pushableRepos { + pushOptions = append(pushOptions, ghrepo.FullName(r)) + if r.RepoOwner() == currentLogin { + hasOwnFork = true + } + } + + if !hasOwnFork { + pushOptions = append(pushOptions, fmt.Sprintf("Create a fork of %s", ghrepo.FullName(baseRepo))) + } + pushOptions = append(pushOptions, "Skip pushing the branch") + pushOptions = append(pushOptions, "Cancel") + + selectedOption, err := opts.Prompter.Select(fmt.Sprintf("Where should we push the '%s' branch?", currentBranch), "", pushOptions) + if err != nil { + return nil, err + } + + if selectedOption < len(pushableRepos) { + // A repository has been selected to push to. + return newCreateContext(pushableRefs{ + headRepo: pushableRepos[selectedOption], + headBranchName: currentBranch, + baseRefs: baseRefs, + }), nil + } else if pushOptions[selectedOption] == "Skip pushing the branch" { + // We're going to skip pushing the branch altogether, meaning, use whatever SHA is already pushed. + // It's not exactly clear what repo the user expects to use here for the HEAD, and maybe we should + // make that clear in the UX somehow, but in the old implementation as far as I can tell, this + // always meant "use the base repo". + return newCreateContext(skipPushRefs{ + qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner(currentBranch), + baseRefs: baseRefs, + }), nil + } else if pushOptions[selectedOption] == "Cancel" { + return nil, cmdutil.CancelError + } else { + // A fork should be created. + return newCreateContext(forkableRefs{ + qualifiedHeadRef: shared.NewQualifiedHeadRef(currentLogin, currentBranch), + baseRefs: baseRefs, + }), nil + } } func getRemotes(opts *CreateOptions) (ghContext.Remotes, error) { @@ -789,8 +973,8 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS "title": state.Title, "body": state.Body, "draft": state.Draft, - "baseRefName": ctx.BaseBranch, - "headRefName": ctx.PrRefs.GetPRHeadLabel(), + "baseRefName": ctx.PRRefs.BaseRef(), + "headRefName": ctx.PRRefs.QualifiedHeadRef(), "maintainerCanModify": opts.MaintainerCanModify, } @@ -798,7 +982,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS return errors.New("pull request title must not be blank") } - err := shared.AddMetadataToIssueParams(client, ctx.PrRefs.BaseRepo, params, &state) + err := shared.AddMetadataToIssueParams(client, ctx.PRRefs.BaseRepo(), params, &state) if err != nil { return err } @@ -812,9 +996,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS } opts.IO.StartProgressIndicator() - // At this point, ctx.PrRefs.BaseRepo is guaranteed to be an *api.Repository - // because of https://github.com/cli/cli/blob/d29db2d44199ad4a987ea866f3f4ff601b1c90a0/pkg/cmd/pr/create/create.go#L578-L592 - pr, err := api.CreatePullRequest(client, ctx.PrRefs.BaseRepo.(*api.Repository), params) + pr, err := api.CreatePullRequest(client, ctx.PRRefs.BaseRepo(), params) opts.IO.StopProgressIndicator() if pr != nil { fmt.Fprintln(opts.IO.Out, pr.URL) @@ -910,38 +1092,43 @@ func previewPR(opts CreateOptions, openURL string) error { } func handlePush(opts CreateOptions, ctx CreateContext) error { - didForkRepo := false - headRepo := ctx.PrRefs.HeadRepo - headRemote := ctx.HeadRemote - client := ctx.Client - gitClient := ctx.GitClient - - var err error - // if a head repository could not be determined so far, automatically create - // one by forking the base repository - if ctx.forkHeadRepo && ctx.isPushEnabled { + refs := ctx.PRRefs + forkableRefs, requiresFork := refs.(forkableRefs) + if requiresFork { opts.IO.StartProgressIndicator() - headRepo, err = api.ForkRepo(client, ctx.PrRefs.BaseRepo, "", "", false) + forkedRepo, err := api.ForkRepo(ctx.Client, forkableRefs.BaseRepo(), "", "", false) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("error forking repo: %w", err) } - didForkRepo = true + + refs = pushableRefs{ + headRepo: forkedRepo, + headBranchName: forkableRefs.qualifiedHeadRef.BranchName(), + baseRefs: baseRefs{ + baseRepo: forkableRefs.baseRepo, + baseBranchName: forkableRefs.baseBranchName, + }, + } } - if headRemote == nil && headRepo != nil { - headRemote, _ = ctx.RepoContext.RemoteForRepo(headRepo) + // We may have upcast to pushableRefs on fork, or we may have been passed an instance + // already. But if we haven't, then there's nothing more to do. + pushableRefs, ok := refs.(pushableRefs) + if !ok { + return nil } // There are two cases when an existing remote for the head repo will be - // missing: + // missing (and an error will be returned): // 1. the head repo was just created by auto-forking; // 2. an existing fork was discovered by querying the API. // In either case, we want to add the head repo as a new git remote so we // can push to it. We will try to add the head repo as the "origin" remote // and fallback to the "fork" remote if it is unavailable. Also, if the // base repo is the "origin" remote we will rename it "upstream". - if headRemote == nil && ctx.isPushEnabled { + headRemote, _ := ctx.ResolvedRemotes.RemoteForRepo(pushableRefs.HeadRepo()) + if headRemote == nil { cfg, err := opts.Config() if err != nil { return err @@ -952,8 +1139,8 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { return err } - cloneProtocol := cfg.GitProtocol(headRepo.RepoHost()).Value - headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol) + cloneProtocol := cfg.GitProtocol(pushableRefs.HeadRepo().RepoHost()).Value + headRepoURL := ghrepo.FormatRemoteURL(pushableRefs.HeadRepo(), cloneProtocol) gitClient := ctx.GitClient origin, _ := remotes.FindByName("origin") upstreamName := "upstream" @@ -964,7 +1151,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { remoteName = "fork" } - if origin != nil && upstream == nil && ghrepo.IsSame(origin, ctx.PrRefs.BaseRepo) { + if origin != nil && upstream == nil && ghrepo.IsSame(origin, pushableRefs.BaseRepo()) { renameCmd, err := gitClient.Command(context.Background(), "remote", "rename", "origin", upstreamName) if err != nil { return err @@ -973,7 +1160,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { return fmt.Errorf("error renaming origin remote: %w", err) } remoteName = "origin" - fmt.Fprintf(opts.IO.ErrOut, "Changed %s remote to %q\n", ghrepo.FullName(ctx.PrRefs.BaseRepo), upstreamName) + fmt.Fprintf(opts.IO.ErrOut, "Changed %s remote to %q\n", ghrepo.FullName(pushableRefs.BaseRepo()), upstreamName) } gitRemote, err := gitClient.AddRemote(context.Background(), remoteName, headRepoURL, []string{}) @@ -981,10 +1168,10 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { return fmt.Errorf("error adding remote: %w", err) } - fmt.Fprintf(opts.IO.ErrOut, "Added %s as remote %q\n", ghrepo.FullName(headRepo), remoteName) + fmt.Fprintf(opts.IO.ErrOut, "Added %s as remote %q\n", ghrepo.FullName(pushableRefs.HeadRepo()), remoteName) // Only mark `upstream` remote as default if `gh pr create` created the remote. - if didForkRepo { + if requiresFork { err := gitClient.SetRemoteResolution(context.Background(), upstreamName, "base") if err != nil { return fmt.Errorf("error setting upstream as default: %w", err) @@ -992,52 +1179,45 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { if opts.IO.IsStdoutTTY() { cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(headRepo))) + fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(pushableRefs.HeadRepo()))) } } headRemote = &ghContext.Remote{ Remote: gitRemote, - Repo: headRepo, + Repo: pushableRefs.HeadRepo(), } } // automatically push the branch if it hasn't been pushed anywhere yet - if ctx.isPushEnabled { - pushBranch := func() error { - w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "") - defer w.Flush() - ref := fmt.Sprintf("HEAD:refs/heads/%s", ctx.PrRefs.BranchName) - bo := backoff.NewConstantBackOff(2 * time.Second) - ctx := context.Background() - return backoff.Retry(func() error { - if err := gitClient.Push(ctx, headRemote.Name, ref, git.WithStderr(w)); err != nil { - // Only retry if we have forked the repo else the push should succeed the first time. - if didForkRepo { - fmt.Fprintf(opts.IO.ErrOut, "waiting 2 seconds before retrying...\n") - return err - } - return backoff.Permanent(err) + pushBranch := func() error { + w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "") + defer w.Flush() + ref := fmt.Sprintf("HEAD:refs/heads/%s", ctx.PRRefs.UnqualifiedHeadRef()) + bo := backoff.NewConstantBackOff(2 * time.Second) + root := context.Background() + return backoff.Retry(func() error { + if err := ctx.GitClient.Push(root, headRemote.Name, ref, git.WithStderr(w)); err != nil { + // Only retry if we have forked the repo else the push should succeed the first time. + if requiresFork { + fmt.Fprintf(opts.IO.ErrOut, "waiting 2 seconds before retrying...\n") + return err } - return nil - }, backoff.WithContext(backoff.WithMaxRetries(bo, 3), ctx)) - } - - err := pushBranch() - if err != nil { - return err - } + return backoff.Permanent(err) + } + return nil + }, backoff.WithContext(backoff.WithMaxRetries(bo, 3), root)) } - return nil + return pushBranch() } func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (string, error) { u := ghrepo.GenerateRepoURL( - ctx.PrRefs.BaseRepo, + ctx.PRRefs.BaseRepo(), "compare/%s...%s?expand=1", - url.PathEscape(ctx.BaseBranch), url.PathEscape(ctx.PrRefs.GetPRHeadLabel())) - url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PrRefs.BaseRepo, u, state) + url.PathEscape(ctx.PRRefs.BaseRef()), url.PathEscape(ctx.PRRefs.QualifiedHeadRef())) + url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PRRefs.BaseRepo(), u, state) if err != nil { return "", err } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 51cbfa724..2a88b5eee 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -2,7 +2,6 @@ package create import ( "encoding/json" - "errors" "fmt" "net/http" "os" @@ -332,19 +331,18 @@ func TestNewCmdCreate(t *testing.T) { func Test_createRun(t *testing.T) { tests := []struct { - name string - setup func(*CreateOptions, *testing.T) func() - cmdStubs func(*run.CommandStubber) - promptStubs func(*prompter.PrompterMock) - httpStubs func(*httpmock.Registry, *testing.T) - expectedOutputs []string - expectedOut string - expectedErrOut string - expectedBrowse string - wantErr string - tty bool - customBranchConfig bool - customPushDestination bool + name string + setup func(*CreateOptions, *testing.T) func() + cmdStubs func(*run.CommandStubber) + promptStubs func(*prompter.PrompterMock) + httpStubs func(*httpmock.Registry, *testing.T) + expectedOutputs []string + expectedOut string + expectedErrOut string + expectedBrowse string + wantErr string + tty bool + customBranchConfig bool }{ { name: "nontty web", @@ -608,7 +606,7 @@ func Test_createRun(t *testing.T) { `), }, { - name: "survey", + name: "select a specific branch to push to on prompt", tty: true, setup: func(opts *CreateOptions, t *testing.T) func() { opts.TitleProvided = true @@ -637,6 +635,9 @@ func Test_createRun(t *testing.T) { })) }, cmdStubs: func(cs *run.CommandStubber) { + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") + cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -651,6 +652,52 @@ func Test_createRun(t *testing.T) { expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", }, + { + name: "skip pushing to branch on prompt", + tty: true, + setup: func(opts *CreateOptions, t *testing.T) func() { + opts.TitleProvided = true + opts.BodyProvided = true + opts.Title = "my title" + opts.Body = "my body" + return func() {} + }, + httpStubs: func(reg *httpmock.Registry, t *testing.T) { + reg.StubRepoResponse("OWNER", "REPO") + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`)) + reg.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } }`, func(input map[string]interface{}) { + assert.Equal(t, "REPOID", input["repositoryId"].(string)) + assert.Equal(t, "my title", input["title"].(string)) + assert.Equal(t, "my body", input["body"].(string)) + assert.Equal(t, "master", input["baseRefName"].(string)) + assert.Equal(t, "feature", input["headRefName"].(string)) + assert.Equal(t, false, input["draft"].(bool)) + })) + }, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") + cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 1, "") + }, + promptStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(p, _ string, opts []string) (int, error) { + if p == "Where should we push the 'feature' branch?" { + return prompter.IndexFor(opts, "Skip pushing the branch") + } else { + return -1, prompter.NoSuchPromptErr(p) + } + } + }, + expectedOut: "https://github.com/OWNER/REPO/pull/12\n", + expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n", + }, { name: "project v2", tty: true, @@ -699,6 +746,9 @@ func Test_createRun(t *testing.T) { })) }, cmdStubs: func(cs *run.CommandStubber) { + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -744,6 +794,9 @@ func Test_createRun(t *testing.T) { })) }, cmdStubs: func(cs *run.CommandStubber) { + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -791,12 +844,11 @@ func Test_createRun(t *testing.T) { assert.Equal(t, "monalisa:feature", input["headRefName"].(string)) })) }, - customPushDestination: true, cmdStubs: func(cs *run.CommandStubber) { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") - cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "") - cs.Register("git config remote.pushDefault", 0, "") - cs.Register("git config push.default", 0, "") + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 1, "") + cs.Register("git config remote.pushDefault", 1, "") + cs.Register("git config push.default", 1, "") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register("git remote rename origin upstream", 0, "") cs.Register(`git remote add origin https://github.com/monalisa/REPO.git`, 0, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") @@ -854,15 +906,11 @@ func Test_createRun(t *testing.T) { assert.Equal(t, "monalisa:feature", input["headRefName"].(string)) })) }, - customPushDestination: true, cmdStubs: func(cs *run.CommandStubber) { - cs.Register("git show-ref --verify", 0, heredoc.Doc(` + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 0, heredoc.Doc(` deadbeef HEAD - deadb00f refs/remotes/upstream/feature deadbeef refs/remotes/origin/feature`)) - cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") - cs.Register("git config remote.pushDefault", 0, "") - cs.Register("git config push.default", 0, "") }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for monalisa:feature into master in OWNER/REPO\n\n", @@ -890,20 +938,17 @@ func Test_createRun(t *testing.T) { assert.Equal(t, "my-feat2", input["headRefName"].(string)) })) }, - customBranchConfig: true, - customPushDestination: true, + customBranchConfig: true, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git config --get-regexp \^branch\\\.feature\\\.`, 0, heredoc.Doc(` branch.feature.remote origin branch.feature.merge refs/heads/my-feat2 - `)) // determineTrackingBranch - cs.Register("git show-ref --verify", 0, heredoc.Doc(` + `)) + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/my-feat2") + cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/my-feat2", 0, heredoc.Doc(` deadbeef HEAD deadbeef refs/remotes/origin/my-feat2 - `)) // determineTrackingBranch - cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/my-feat2") - cs.Register("git config remote.pushDefault", 0, "") - cs.Register("git config push.default", 0, "") + `)) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for my-feat2 into master in OWNER/REPO\n\n", @@ -1084,6 +1129,9 @@ func Test_createRun(t *testing.T) { }, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "") + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -1115,6 +1163,9 @@ func Test_createRun(t *testing.T) { }, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "") + cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "") cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, promptStubs: func(pm *prompter.PrompterMock) { @@ -1279,37 +1330,6 @@ func Test_createRun(t *testing.T) { }, wantErr: "cannot open in browser: maximum URL length exceeded", }, - { - name: "no local git repo", - setup: func(opts *CreateOptions, t *testing.T) func() { - opts.Title = "My PR" - opts.TitleProvided = true - opts.Body = "" - opts.BodyProvided = true - opts.HeadBranch = "feature" - opts.RepoOverride = "OWNER/REPO" - opts.Remotes = func() (context.Remotes, error) { - return nil, errors.New("not a git repository") - } - return func() {} - }, - httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`mutation PullRequestCreate\b`), - httpmock.StringResponse(` - { "data": { "createPullRequest": { "pullRequest": { - "URL": "https://github.com/OWNER/REPO/pull/12" - } } } } - `)) - }, - customPushDestination: true, - cmdStubs: func(cs *run.CommandStubber) { - cs.Register("git rev-parse --abbrev-ref feature@{push}", 1, "fatal: not a git repository (or any of the parent directories): .git") - cs.Register("git config remote.pushDefault", 1, "") - cs.Register("git config push.default", 1, "") - }, - expectedOut: "https://github.com/OWNER/REPO/pull/12\n", - }, { name: "single commit title and body are used", tty: true, @@ -1528,20 +1548,16 @@ func Test_createRun(t *testing.T) { assert.Equal(t, "monalisa:task1", input["headRefName"].(string)) })) }, - customBranchConfig: true, - customPushDestination: true, + customBranchConfig: true, cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git config --get-regexp \^branch\\\.task1\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, heredoc.Doc(` branch.task1.remote origin branch.task1.merge refs/heads/task1 branch.task1.gh-merge-base feature/feat2`)) // ReadBranchConfig - cs.Register(`git show-ref --verify`, 0, heredoc.Doc(` + cs.Register("git rev-parse --symbolic-full-name task1@{push}", 0, "refs/remotes/origin/task1") + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/task1`, 0, heredoc.Doc(` deadbeef HEAD - deadb00f refs/remotes/upstream/feature/feat2 - deadbeef refs/remotes/origin/task1`)) // determineTrackingBranch - cs.Register("git rev-parse --abbrev-ref task1@{push}", 0, "origin/task1") - cs.Register("git config remote.pushDefault", 0, "") - cs.Register("git config push.default", 0, "") + deadbeef refs/remotes/origin/task1`)) }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", expectedErrOut: "\nCreating pull request for monalisa:task1 into feature/feat2 in OWNER/REPO\n\n", @@ -1571,12 +1587,6 @@ func Test_createRun(t *testing.T) { opts.HeadBranch = "otherowner:feature" return func() {} }, - customPushDestination: true, - cmdStubs: func(cs *run.CommandStubber) { - cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") - cs.Register("git config remote.pushDefault", 0, "") - cs.Register("git config push.default", 0, "") - }, expectedOut: "https://github.com/OWNER/REPO/pull/12\n", }, } @@ -1598,16 +1608,7 @@ func Test_createRun(t *testing.T) { cs, cmdTeardown := run.Stub() defer cmdTeardown(t) - cs.Register(`git status --porcelain`, 0, "") - // TODO this could be values in the test struct with a helper - // function to invoke the appropriate command stubs based on - // those values. - if !tt.customPushDestination { - cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "") - cs.Register("git rev-parse --abbrev-ref feature@{push}", 0, "origin/feature") - cs.Register("git config remote.pushDefault", 0, "") - cs.Register("git config push.default", 0, "") - } + if !tt.customBranchConfig { cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") } @@ -1658,6 +1659,10 @@ func Test_createRun(t *testing.T) { } defer cleanSetup() + if opts.HeadBranch == "" { + cs.Register(`git status --porcelain`, 0, "") + } + err := createRun(&opts) output := &test.CmdOut{ OutBuf: stdout, @@ -1681,6 +1686,168 @@ func Test_createRun(t *testing.T) { } } +func TestRemoteGuessing(t *testing.T) { + // Given git config does not provide the necessary info to determine a remote + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git status --porcelain`, 0, "") + cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") + cs.Register(`git rev-parse --symbolic-full-name feature@{push}`, 1, "") + cs.Register("git config remote.pushDefault", 1, "") + cs.Register("git config push.default", 1, "") + + // And Given there is a remote on a SHA that matches the current HEAD + cs.Register(`git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature`, 0, heredoc.Doc(` + deadbeef HEAD + deadb00f refs/remotes/upstream/feature + deadbeef refs/remotes/origin/feature`)) + + // When the command is run + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "master") + defer reg.Verify(t) + + reg.Register( + httpmock.GraphQL(`mutation PullRequestCreate\b`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } }`, func(input map[string]interface{}) { + assert.Equal(t, "REPOID", input["repositoryId"].(string)) + assert.Equal(t, "master", input["baseRefName"].(string)) + assert.Equal(t, "OTHEROWNER:feature", input["headRefName"].(string)) + })) + + ios, _, _, _ := iostreams.Test() + + opts := CreateOptions{ + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Config: func() (gh.Config, error) { + return config.NewBlankConfig(), nil + }, + Browser: &browser.Stub{}, + IO: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{ + GhPath: "some/path/gh", + GitPath: "some/path/git", + }, + Finder: shared.NewMockFinder("feature", nil, nil), + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{ + Name: "upstream", + Resolved: "base", + }, + Repo: ghrepo.New("OWNER", "REPO"), + }, + { + Remote: &git.Remote{ + Name: "origin", + }, + Repo: ghrepo.New("OTHEROWNER", "REPO-FORK"), + }, + }, nil + }, + Branch: func() (string, error) { + return "feature", nil + }, + + TitleProvided: true, + BodyProvided: true, + Title: "my title", + Body: "my body", + } + + require.NoError(t, createRun(&opts)) + + // Then guessed remote is used for the PR head, + // which annoyingly, is asserted above on the line: + // assert.Equal(t, "OTHEROWNER:feature", input["headRefName"].(string)) + // + // This is because OTHEROWNER relates to the "origin" remote, which has a + // SHA that matches the HEAD ref in the `git show-ref` output. +} + +func TestNoRepoCanBeDetermined(t *testing.T) { + // Given no head repo can be determined from git config + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git status --porcelain`, 0, "") + cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") + cs.Register(`git rev-parse --symbolic-full-name feature@{push}`, 1, "") + cs.Register("git config remote.pushDefault", 1, "") + cs.Register("git config push.default", 1, "") + + // And Given there is no remote on the correct SHA + cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, heredoc.Doc(` + deadbeef HEAD + deadb00f refs/remotes/origin/feature`)) + + // When the command is run with no TTY + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "master") + defer reg.Verify(t) + + ios, _, _, stderr := iostreams.Test() + + opts := CreateOptions{ + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Config: func() (gh.Config, error) { + return config.NewBlankConfig(), nil + }, + Browser: &browser.Stub{}, + IO: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{ + GhPath: "some/path/gh", + GitPath: "some/path/git", + }, + Finder: shared.NewMockFinder("feature", nil, nil), + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{ + Name: "origin", + Resolved: "base", + }, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + return "feature", nil + }, + + TitleProvided: true, + BodyProvided: true, + Title: "my title", + Body: "my body", + } + + // When we run the command + err := createRun(&opts) + + // Then create fails + require.Equal(t, cmdutil.SilentError, err) + assert.Equal(t, "aborted: you must first push the current branch to a remote, or use the --head flag\n", stderr.String()) +} + +func mustParseQualifiedHeadRef(ref string) shared.QualifiedHeadRef { + parsed, err := shared.ParseQualifiedHeadRef(ref) + if err != nil { + panic(err) + } + return parsed +} + func Test_generateCompareURL(t *testing.T) { tests := []struct { name string @@ -1692,12 +1859,13 @@ func Test_generateCompareURL(t *testing.T) { { name: "basic", ctx: CreateContext{ - PrRefs: shared.PullRequestRefs{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), - HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), - BranchName: "feature", + PRRefs: &skipPushRefs{ + qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"), + baseRefs: baseRefs{ + baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + baseBranchName: "main", + }, }, - BaseBranch: "main", }, want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1", wantErr: false, @@ -1705,12 +1873,13 @@ func Test_generateCompareURL(t *testing.T) { { name: "with labels", ctx: CreateContext{ - PrRefs: shared.PullRequestRefs{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), - HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), - BranchName: "b", + PRRefs: &skipPushRefs{ + qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("b"), + baseRefs: baseRefs{ + baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + baseBranchName: "a", + }, }, - BaseBranch: "a", }, state: shared.IssueMetadataState{ Labels: []string{"one", "two three"}, @@ -1721,12 +1890,13 @@ func Test_generateCompareURL(t *testing.T) { { name: "'/'s in branch names/labels are percent-encoded", ctx: CreateContext{ - PrRefs: shared.PullRequestRefs{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"), - HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "ORIGINOWNER"}}, "github.com"), - BranchName: "feature", + PRRefs: &skipPushRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("ORIGINOWNER:feature"), + baseRefs: baseRefs{ + baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"), + baseBranchName: "main/trunk", + }, }, - BaseBranch: "main/trunk", }, want: "https://github.com/UPSTREAMOWNER/REPO/compare/main%2Ftrunk...ORIGINOWNER:feature?body=&expand=1", wantErr: false, @@ -1734,18 +1904,19 @@ func Test_generateCompareURL(t *testing.T) { { name: "Any of !'(),; but none of $&+=@ and : in branch names/labels are percent-encoded ", /* - - Technically, per section 3.3 of RFC 3986, none of !$&'()*+,;= (sub-delims) and :[]@ (part of gen-delims) in path segments are optionally percent-encoded, but url.PathEscape percent-encodes !'(),; anyway - - !$&'()+,;=@ is a valid Git branch name—essentially RFC 3986 sub-delims without * and gen-delims without :/?#[] - - : is GitHub separator between a fork name and a branch name - - See https://github.com/golang/go/issues/27559. + - Technically, per section 3.3 of RFC 3986, none of !$&'()*+,;= (sub-delims) and :[]@ (part of gen-delims) in path segments are optionally percent-encoded, but url.PathEscape percent-encodes !'(),; anyway + - !$&'()+,;=@ is a valid Git branch name—essentially RFC 3986 sub-delims without * and gen-delims without :/?#[] + - : is GitHub separator between a fork name and a branch name + - See https://github.com/golang/go/issues/27559. */ ctx: CreateContext{ - PrRefs: shared.PullRequestRefs{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"), - HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "ORIGINOWNER"}}, "github.com"), - BranchName: "!$&'()+,;=@", + PRRefs: &skipPushRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("ORIGINOWNER:!$&'()+,;=@"), + baseRefs: baseRefs{ + baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"), + baseBranchName: "main/trunk", + }, }, - BaseBranch: "main/trunk", }, want: "https://github.com/UPSTREAMOWNER/REPO/compare/main%2Ftrunk...ORIGINOWNER:%21$&%27%28%29+%2C%3B=@?body=&expand=1", wantErr: false, @@ -1753,12 +1924,13 @@ func Test_generateCompareURL(t *testing.T) { { name: "with template", ctx: CreateContext{ - PrRefs: shared.PullRequestRefs{ - BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), - HeadRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), - BranchName: "feature", + PRRefs: &skipPushRefs{ + qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"), + baseRefs: baseRefs{ + baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + baseBranchName: "main", + }, }, - BaseBranch: "main", }, state: shared.IssueMetadataState{ Template: "story.md", diff --git a/pkg/cmd/pr/shared/find_refs_resolution.go b/pkg/cmd/pr/shared/find_refs_resolution.go new file mode 100644 index 000000000..833075af8 --- /dev/null +++ b/pkg/cmd/pr/shared/find_refs_resolution.go @@ -0,0 +1,394 @@ +package shared + +import ( + "context" + "fmt" + "net/url" + "strings" + + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" + + "github.com/cli/cli/v2/internal/ghrepo" + o "github.com/cli/cli/v2/pkg/option" +) + +// QualifiedHeadRef represents a git branch with an optional owner, used +// for the head of a pull request. For example, within a single repository, +// we would expect a PR to have a head ref of no owner, and a branch name. +// However, for cross-repository pull requests, we would expect a head ref +// with an owner and a branch name. In string form this is represented as +// :. The GitHub API is able to interpret this format in order +// to discover the correct fork repository. +// +// In other parts of the code, you may see this refered to as a HeadLabel. +type QualifiedHeadRef struct { + owner o.Option[string] + branchName string +} + +// NewQualifiedHeadRef creates a QualifiedHeadRef. If the empty string is provided +// for the owner, it will be treated as None. +func NewQualifiedHeadRef(owner string, branchName string) QualifiedHeadRef { + return QualifiedHeadRef{ + owner: o.SomeIfNonZero(owner), + branchName: branchName, + } +} + +func NewQualifiedHeadRefWithoutOwner(branchName string) QualifiedHeadRef { + return QualifiedHeadRef{ + owner: o.None[string](), + branchName: branchName, + } +} + +// ParseQualifiedHeadRef takes strings of the form : or +// and returns a QualifiedHeadRef. If the form : is used, +// the owner is set to the value of , and the branch name is set to +// the value of . If the form is used, the owner is set to +// None, and the branch name is set to the value of . +// +// This does no further error checking about the validity of a ref, so +// it is not safe to assume the ref is truly a valid ref, e.g. "my~bad:ref?" +// is going to result in a nonsense result. +func ParseQualifiedHeadRef(ref string) (QualifiedHeadRef, error) { + if !strings.Contains(ref, ":") { + return NewQualifiedHeadRefWithoutOwner(ref), nil + } + + parts := strings.Split(ref, ":") + if len(parts) != 2 { + return QualifiedHeadRef{}, fmt.Errorf("invalid qualified head ref format '%s'", ref) + } + + return NewQualifiedHeadRef(parts[0], parts[1]), nil +} + +// A QualifiedHeadRef without an owner returns , while a QualifiedHeadRef +// with an owner returns :. +func (r QualifiedHeadRef) String() string { + if owner, present := r.owner.Value(); present { + return fmt.Sprintf("%s:%s", owner, r.branchName) + } + return r.branchName +} + +func (r QualifiedHeadRef) BranchName() string { + return r.branchName +} + +// PRFindRefs represents the necessary data to find a pull request from the API. +type PRFindRefs struct { + qualifiedHeadRef QualifiedHeadRef + + baseRepo ghrepo.Interface + // baseBranchName is an optional branch name, because it is not required for + // finding a pull request, only for disambiguation if multiple pull requests + // contain the same head ref. + baseBranchName o.Option[string] +} + +// QualifiedHeadRef returns a stringified form of the head ref, varying depending +// on whether the head ref is in the same repository as the base ref. If they are +// the same repository, we return the branch name only. If they are different repositories, +// we return the owner and branch name in the form :. +func (r PRFindRefs) QualifiedHeadRef() string { + return r.qualifiedHeadRef.String() +} + +func (r PRFindRefs) UnqualifiedHeadRef() string { + return r.qualifiedHeadRef.BranchName() +} + +// Matches checks whether the provided baseBranchName and headRef match the refs. +// It is used to determine whether Pull Requests returned from the API +func (r PRFindRefs) Matches(baseBranchName, qualifiedHeadRef string) bool { + headMatches := qualifiedHeadRef == r.QualifiedHeadRef() + baseMatches := r.baseBranchName.IsNone() || baseBranchName == r.baseBranchName.Unwrap() + return headMatches && baseMatches +} + +func (r PRFindRefs) BaseRepo() ghrepo.Interface { + return r.baseRepo +} + +type RemoteNameToRepoFn func(remoteName string) (ghrepo.Interface, error) + +// PullRequestFindRefsResolver interrogates git configuration to try and determine +// a head repository and a remote branch name, from a local branch name. +type PullRequestFindRefsResolver struct { + GitConfigClient GitConfigClient + RemoteNameToRepoFn RemoteNameToRepoFn +} + +func NewPullRequestFindRefsResolver(gitConfigClient GitConfigClient, remotesFn func() (ghContext.Remotes, error)) PullRequestFindRefsResolver { + return PullRequestFindRefsResolver{ + GitConfigClient: gitConfigClient, + RemoteNameToRepoFn: newRemoteNameToRepoFn(remotesFn), + } +} + +// ResolvePullRequests takes a base repository, a base branch name and a local branch name and uses the git configuration to +// determine the head repository and remote branch name. If we were unable to determine this from git, we default the head +// repository to the base repository. +func (r *PullRequestFindRefsResolver) ResolvePullRequestRefs(baseRepo ghrepo.Interface, baseBranchName, localBranchName string) (PRFindRefs, error) { + if baseRepo == nil { + return PRFindRefs{}, fmt.Errorf("find pull request ref resolution cannot be performed without a base repository") + } + + if localBranchName == "" { + return PRFindRefs{}, fmt.Errorf("find pull request ref resolution cannot be performed without a local branch name") + } + + headPRRef, err := TryDetermineDefaultPRHead(r.GitConfigClient, remoteToRepoResolver{r.RemoteNameToRepoFn}, localBranchName) + if err != nil { + return PRFindRefs{}, err + } + + // If the headRepo was resolved, we can just convert the response + // to refs and return it. + if headRepo, present := headPRRef.Repo.Value(); present { + qualifiedHeadRef := NewQualifiedHeadRefWithoutOwner(headPRRef.BranchName) + if !ghrepo.IsSame(headRepo, baseRepo) { + qualifiedHeadRef = NewQualifiedHeadRef(headRepo.RepoOwner(), headPRRef.BranchName) + } + + return PRFindRefs{ + qualifiedHeadRef: qualifiedHeadRef, + baseRepo: baseRepo, + baseBranchName: o.SomeIfNonZero(baseBranchName), + }, nil + } + + // If we didn't find a head repo, default to the base repo + return PRFindRefs{ + qualifiedHeadRef: NewQualifiedHeadRefWithoutOwner(headPRRef.BranchName), + baseRepo: baseRepo, + baseBranchName: o.SomeIfNonZero(baseBranchName), + }, nil +} + +// DefaultPRHead is a neighbour to defaultPushTarget, but instead of holding +// basic git remote information, it holds a resolved repository in `gh` terms. +// +// Since we may not be able to determine a default remote for a branch, this +// is also true of the resolved repository. +type DefaultPRHead struct { + Repo o.Option[ghrepo.Interface] + BranchName string +} + +// TryDetermineDefaultPRHead is a thin wrapper around determineDefaultPushTarget, which attempts to convert +// a present remote into a resolved repository. If the remote is not present, we indicate that to the caller +// by returning a None value for the repo. +func TryDetermineDefaultPRHead(gitClient GitConfigClient, remoteToRepo remoteToRepoResolver, branch string) (DefaultPRHead, error) { + pushTarget, err := tryDetermineDefaultPushTarget(gitClient, branch) + if err != nil { + return DefaultPRHead{}, err + } + + // If we have no remote, let the caller decide what to do by indicating that with a None. + if pushTarget.remote.IsNone() { + return DefaultPRHead{ + Repo: o.None[ghrepo.Interface](), + BranchName: pushTarget.branchName, + }, nil + } + + repo, err := remoteToRepo.resolve(pushTarget.remote.Unwrap()) + if err != nil { + return DefaultPRHead{}, err + } + + return DefaultPRHead{ + Repo: o.Some(repo), + BranchName: pushTarget.branchName, + }, nil +} + +// remote represents the value of the remote key in a branch's git configuration. +// This value may be a name or a URL, both of which are strings, but are unfortunately +// parsed by ReadBranchConfig into separate fields, allowing for illegal states to be +// created by accident. This is an attempt to indicate that they are mutally exclusive. +type remote interface{ sealedRemote() } + +type remoteName struct{ name string } + +func (rn remoteName) sealedRemote() {} + +type remoteURL struct{ url *url.URL } + +func (ru remoteURL) sealedRemote() {} + +// newRemoteNameToRepoFn takes a function that returns a list of remotes and +// returns a function that takes a remote name and returns the corresponding +// repository. It is a convenience function to call sites having to duplicate +// the same logic. +func newRemoteNameToRepoFn(remotesFn func() (ghContext.Remotes, error)) RemoteNameToRepoFn { + return func(remoteName string) (ghrepo.Interface, error) { + remotes, err := remotesFn() + if err != nil { + return nil, err + } + repo, err := remotes.FindByName(remoteName) + if err != nil { + return nil, err + } + return repo, nil + } +} + +// remoteToRepoResolver provides a utility method to resolve a remote (either name or URL) +// to a repo (ghrepo.Interface). +type remoteToRepoResolver struct { + remoteNameToRepo RemoteNameToRepoFn +} + +func NewRemoteToRepoResolver(remotesFn func() (ghContext.Remotes, error)) remoteToRepoResolver { + return remoteToRepoResolver{ + remoteNameToRepo: newRemoteNameToRepoFn(remotesFn), + } +} + +// resolve takes a remote and returns a repository representing it. +func (r remoteToRepoResolver) resolve(remote remote) (ghrepo.Interface, error) { + switch v := remote.(type) { + case remoteName: + repo, err := r.remoteNameToRepo(v.name) + if err != nil { + return nil, fmt.Errorf("could not resolve remote %q: %w", v.name, err) + } + return repo, nil + case remoteURL: + repo, err := ghrepo.FromURL(v.url) + if err != nil { + return nil, fmt.Errorf("could not parse remote URL %q: %w", v.url, err) + } + return repo, nil + default: + return nil, fmt.Errorf("unsupported remote type %T, value: %v", v, remote) + } +} + +// A defaultPushTarget represents the remote name or URL and a branch name +// that we would expect a branch to be pushed to if `git push` were run with +// no further arguments. This is the most likely place for the head of the PR +// to be, but it's not guaranteed. The user may have pushed to another branch +// directly via `git push :` and not set up tracking information. +// A branch name is always present. +// +// It's possible that we're unable to determine a remote, if the user had pushed directly +// to a URL for example `git push `, which is why it is optional. When present, +// the remote may either be a name or a URL. +type defaultPushTarget struct { + remote o.Option[remote] + branchName string +} + +// newDefaultPushTarget is a thin wrapper over defaultPushTarget to help with +// generic type inference, to reduce verbosity in repeating the parametric type. +func newDefaultPushTarget(remote remote, branchName string) defaultPushTarget { + return defaultPushTarget{ + remote: o.Some(remote), + branchName: branchName, + } +} + +// tryDetermineDefaultPushTarget uses git configuration to make a best guess about where a branch +// is pushed to, and where it would be pushed to if the user ran `git push` with no additional +// arguments. +// +// Firstly, it attempts to resolve the @{push} ref, which is the most reliable method, as this +// is what git uses to determine the remote tracking branch +// +// If this fails, we go through a series of steps to determine the remote: +// +// 1. check branch configuration for `branch..pushRemote = | ` +// 2. check remote configuration for `remote.pushDefault = ` +// 3. check branch configuration for `branch..remote = | ` +// +// If none of these are set, we indicate that we were unable to determine the +// remote by returning a None value for the remote. +// +// The branch name is always set. The default configuration for push.default (current) indicates +// that a git push should use the same remote branch name as the local branch name. If push.default +// is set to upstream or tracking (deprecated form of upstream), then we use the branch name from the merge ref. +func tryDetermineDefaultPushTarget(gitClient GitConfigClient, localBranchName string) (defaultPushTarget, error) { + // If @{push} resolves, then we have the remote tracking branch already, no problem. + if pushRevisionRef, err := gitClient.PushRevision(context.Background(), localBranchName); err == nil { + return newDefaultPushTarget(remoteName{pushRevisionRef.Remote}, pushRevisionRef.Branch), nil + } + + // But it doesn't always resolve, so we can suppress the error and move on to other means + // of determination. We'll first look at branch and remote configuration to make a determination. + branchConfig, err := gitClient.ReadBranchConfig(context.Background(), localBranchName) + if err != nil { + return defaultPushTarget{}, err + } + + pushDefault, err := gitClient.PushDefault(context.Background()) + if err != nil { + return defaultPushTarget{}, err + } + + // We assume the PR's branch name is the same as whatever was provided, unless the user has specified + // push.default = upstream or tracking, then we use the branch name from the merge ref. + remoteBranch := localBranchName + if pushDefault == git.PushDefaultUpstream || pushDefault == git.PushDefaultTracking { + remoteBranch = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") + if remoteBranch == "" { + return defaultPushTarget{}, fmt.Errorf("could not determine remote branch name") + } + } + + // To get the remote, we look to the git config. It comes from one of the following, in order of precedence: + // 1. branch..pushRemote (which may be a name or a URL) + // 2. remote.pushDefault (which is a remote name) + // 3. branch..remote (which may be a name or a URL) + if branchConfig.PushRemoteName != "" { + return newDefaultPushTarget( + remoteName{branchConfig.PushRemoteName}, + remoteBranch, + ), nil + } + + if branchConfig.PushRemoteURL != nil { + return newDefaultPushTarget( + remoteURL{branchConfig.PushRemoteURL}, + remoteBranch, + ), nil + } + + remotePushDefault, err := gitClient.RemotePushDefault(context.Background()) + if err != nil { + return defaultPushTarget{}, err + } + + if remotePushDefault != "" { + return newDefaultPushTarget( + remoteName{remotePushDefault}, + remoteBranch, + ), nil + } + + if branchConfig.RemoteName != "" { + return newDefaultPushTarget( + remoteName{branchConfig.RemoteName}, + remoteBranch, + ), nil + } + + if branchConfig.RemoteURL != nil { + return newDefaultPushTarget( + remoteURL{branchConfig.RemoteURL}, + remoteBranch, + ), nil + } + + // If we couldn't find the remote, we'll indicate that to the caller via None. + return defaultPushTarget{ + remote: o.None[remote](), + branchName: remoteBranch, + }, nil +} diff --git a/pkg/cmd/pr/shared/find_refs_resolution_test.go b/pkg/cmd/pr/shared/find_refs_resolution_test.go new file mode 100644 index 000000000..8cbb62146 --- /dev/null +++ b/pkg/cmd/pr/shared/find_refs_resolution_test.go @@ -0,0 +1,508 @@ +package shared + +import ( + "errors" + "net/url" + "testing" + + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/ghrepo" + o "github.com/cli/cli/v2/pkg/option" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQualifiedHeadRef(t *testing.T) { + t.Parallel() + + testCases := []struct { + behavior string + ref string + expectedString string + expectedBranchName string + expectedError error + }{ + { + behavior: "when a branch is provided, the parsed qualified head ref only has a branch", + ref: "feature-branch", + expectedString: "feature-branch", + expectedBranchName: "feature-branch", + }, + { + behavior: "when an owner and branch are provided, the parsed qualified head ref has both", + ref: "owner:feature-branch", + expectedString: "owner:feature-branch", + expectedBranchName: "feature-branch", + }, + { + behavior: "when the structure cannot be interpreted correctly, an error is returned", + ref: "owner:feature-branch:extra", + expectedError: errors.New("invalid qualified head ref format 'owner:feature-branch:extra'"), + }, + } + + for _, tc := range testCases { + t.Run(tc.behavior, func(t *testing.T) { + t.Parallel() + + qualifiedHeadRef, err := ParseQualifiedHeadRef(tc.ref) + if tc.expectedError != nil { + require.Equal(t, tc.expectedError, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedString, qualifiedHeadRef.String()) + assert.Equal(t, tc.expectedBranchName, qualifiedHeadRef.BranchName()) + }) + } +} + +func TestPRFindRefs(t *testing.T) { + t.Parallel() + + t.Run("qualified head ref with owner", func(t *testing.T) { + t.Parallel() + + refs := PRFindRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("forkowner:feature-branch"), + } + + require.Equal(t, "forkowner:feature-branch", refs.QualifiedHeadRef()) + require.Equal(t, "feature-branch", refs.UnqualifiedHeadRef()) + }) + + t.Run("qualified head ref without owner", func(t *testing.T) { + t.Parallel() + + refs := PRFindRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"), + } + + require.Equal(t, "feature-branch", refs.QualifiedHeadRef()) + require.Equal(t, "feature-branch", refs.UnqualifiedHeadRef()) + }) + + t.Run("base repo", func(t *testing.T) { + t.Parallel() + + refs := PRFindRefs{ + baseRepo: ghrepo.New("owner", "repo"), + } + + require.True(t, ghrepo.IsSame(refs.BaseRepo(), ghrepo.New("owner", "repo")), "expected repos to be the same") + }) + + t.Run("matches", func(t *testing.T) { + t.Parallel() + + testCases := []struct { + behavior string + refs PRFindRefs + baseBranchName string + qualifiedHeadRef string + expectedMatch bool + }{ + { + behavior: "when qualified head refs don't match, returns false", + refs: PRFindRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("owner:feature-branch"), + }, + baseBranchName: "feature-branch", + qualifiedHeadRef: "feature-branch", + expectedMatch: false, + }, + { + behavior: "when base branches don't match, returns false", + refs: PRFindRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"), + baseBranchName: o.Some("not-main"), + }, + baseBranchName: "main", + qualifiedHeadRef: "feature-branch", + expectedMatch: false, + }, + { + behavior: "when head refs match and there is no base branch, returns true", + refs: PRFindRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"), + baseBranchName: o.None[string](), + }, + baseBranchName: "main", + qualifiedHeadRef: "feature-branch", + expectedMatch: true, + }, + { + behavior: "when head refs match and base branches match, returns true", + refs: PRFindRefs{ + qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"), + baseBranchName: o.Some("main"), + }, + baseBranchName: "main", + qualifiedHeadRef: "feature-branch", + expectedMatch: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.behavior, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.expectedMatch, tc.refs.Matches(tc.baseBranchName, tc.qualifiedHeadRef)) + }) + } + }) +} + +func TestPullRequestResolution(t *testing.T) { + t.Parallel() + + baseRepo := ghrepo.New("owner", "repo") + baseRemote := ghContext.Remote{ + Remote: &git.Remote{ + Name: "upstream", + }, + Repo: ghrepo.New("owner", "repo"), + } + + forkRemote := ghContext.Remote{ + Remote: &git.Remote{ + Name: "origin", + }, + Repo: ghrepo.New("otherowner", "repo-fork"), + } + + t.Run("when the base repo is nil, returns an error", func(t *testing.T) { + t.Parallel() + + resolver := NewPullRequestFindRefsResolver(stubGitConfigClient{}, dummyRemotesFn) + _, err := resolver.ResolvePullRequestRefs(nil, "", "") + require.Error(t, err) + }) + + t.Run("when the local branch name is empty, returns an error", func(t *testing.T) { + t.Parallel() + + resolver := NewPullRequestFindRefsResolver(stubGitConfigClient{}, dummyRemotesFn) + _, err := resolver.ResolvePullRequestRefs(baseRepo, "", "") + require.Error(t, err) + }) + + t.Run("when the default pr head has a repo, it is used for the refs", func(t *testing.T) { + t.Parallel() + + // Push revision is the first thing checked for resolution, + // so nothing else needs to be stubbed. + repoResolvedFromPushRevisionClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{ + Remote: "origin", + Branch: "feature-branch", + }, nil), + } + + resolver := NewPullRequestFindRefsResolver( + repoResolvedFromPushRevisionClient, + stubRemotes(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + ) + + refs, err := resolver.ResolvePullRequestRefs(baseRepo, "main", "feature-branch") + require.NoError(t, err) + + expectedRefs := PRFindRefs{ + qualifiedHeadRef: QualifiedHeadRef{ + owner: o.Some("otherowner"), + branchName: "feature-branch", + }, + baseRepo: baseRepo, + baseBranchName: o.Some("main"), + } + + require.Equal(t, expectedRefs, refs) + }) + + t.Run("when the default pr head does not have a repo, we use the base repo for the head", func(t *testing.T) { + t.Parallel() + + // All the values stubbed here result in being unable to resolve a default repo. + noRepoResolutionStubClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault("", nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + } + + resolver := NewPullRequestFindRefsResolver( + noRepoResolutionStubClient, + stubRemotes(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + ) + + refs, err := resolver.ResolvePullRequestRefs(baseRepo, "main", "feature-branch") + require.NoError(t, err) + + expectedRefs := PRFindRefs{ + qualifiedHeadRef: QualifiedHeadRef{ + owner: o.None[string](), + branchName: "feature-branch", + }, + baseRepo: baseRepo, + baseBranchName: o.Some("main"), + } + require.Equal(t, expectedRefs, refs) + }) +} + +func TestTryDetermineDefaultPRHead(t *testing.T) { + t.Parallel() + + baseRepo := ghrepo.New("owner", "repo") + baseRemote := ghContext.Remote{ + Remote: &git.Remote{ + Name: "upstream", + }, + Repo: baseRepo, + } + + forkRepo := ghrepo.New("otherowner", "repo-fork") + forkRemote := ghContext.Remote{ + Remote: &git.Remote{ + Name: "origin", + }, + Repo: forkRepo, + } + forkRepoURL, err := url.Parse("https://github.com/otherowner/repo-fork.git") + require.NoError(t, err) + + t.Run("when the push revision is set, use that", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRevisionClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{ + Remote: "origin", + Branch: "remote-feature-branch", + }, nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRevisionClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "remote-feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when the branch config push remote is set to a name, use that", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + PushRemoteName: "origin", + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when the branch config push remote is set to a URL, use that", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + PushRemoteURL: forkRepoURL, + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + dummyRemoteToRepoResolver(), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when a remote push default is set, use that", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil), + remotePushDefaultFn: stubRemotePushDefault("origin", nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when the branch config remote is set to a name, use that", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + RemoteName: "origin", + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when the branch config remote is set to a URL, use that", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + RemoteURL: forkRepoURL, + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + dummyRemoteToRepoResolver(), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when git didn't provide the necessary information, return none for the remote", func(t *testing.T) { + t.Parallel() + + // All the values stubbed here result in being unable to resolve a default repo. + noRepoResolutionStubClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault("", nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + noRepoResolutionStubClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, defaultPRHead.Repo.IsNone(), "expected repo to be none") + require.Equal(t, "feature-branch", defaultPRHead.BranchName) + }) + + t.Run("when the push default is tracking or upstream, use the merge ref", func(t *testing.T) { + t.Parallel() + + testCases := []struct { + pushDefault git.PushDefault + }{ + {pushDefault: git.PushDefaultTracking}, + {pushDefault: git.PushDefaultUpstream}, + } + + for _, tc := range testCases { + t.Run(string(tc.pushDefault), func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + PushRemoteName: "origin", + MergeRef: "main", + }, nil), + pushDefaultFn: stubPushDefault(tc.pushDefault, nil), + } + + defaultPRHead, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.NoError(t, err) + + require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same") + require.Equal(t, "main", defaultPRHead.BranchName) + }) + } + + t.Run("but if the merge ref is empty, error", func(t *testing.T) { + t.Parallel() + + repoResolvedFromPushRemoteClient := stubGitConfigClient{ + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")), + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + PushRemoteName: "origin", + MergeRef: "", // intentionally empty + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultUpstream, nil), + } + + _, err := TryDetermineDefaultPRHead( + repoResolvedFromPushRemoteClient, + stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), + "feature-branch", + ) + require.Error(t, err) + }) + }) + +} + +func dummyRemotesFn() (ghContext.Remotes, error) { + panic("remotes fn not implemented") +} + +func dummyRemoteToRepoResolver() remoteToRepoResolver { + return NewRemoteToRepoResolver(dummyRemotesFn) +} + +func stubRemoteToRepoResolver(remotes ghContext.Remotes, err error) remoteToRepoResolver { + return NewRemoteToRepoResolver(func() (ghContext.Remotes, error) { + return remotes, err + }) +} + +func mustParseQualifiedHeadRef(ref string) QualifiedHeadRef { + parsed, err := ParseQualifiedHeadRef(ref) + if err != nil { + panic(err) + } + return parsed +} diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 7fed231cb..6d36ef816 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -13,11 +13,12 @@ import ( "time" "github.com/cli/cli/v2/api" - remotes "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" + o "github.com/cli/cli/v2/pkg/option" "github.com/cli/cli/v2/pkg/set" "github.com/shurcooL/githubv4" "golang.org/x/sync/errgroup" @@ -32,16 +33,20 @@ type progressIndicator interface { StopProgressIndicator() } +type GitConfigClient interface { + ReadBranchConfig(ctx context.Context, branchName string) (git.BranchConfig, error) + PushDefault(ctx context.Context) (git.PushDefault, error) + RemotePushDefault(ctx context.Context) (string, error) + PushRevision(ctx context.Context, branchName string) (git.RemoteTrackingRef, error) +} + type finder struct { - baseRepoFn func() (ghrepo.Interface, error) - branchFn func() (string, error) - remotesFn func() (remotes.Remotes, error) - httpClient func() (*http.Client, error) - pushDefault func() (string, error) - remotePushDefault func() (string, error) - parsePushRevision func(string) (string, error) - branchConfig func(string) (git.BranchConfig, error) - progress progressIndicator + baseRepoFn func() (ghrepo.Interface, error) + branchFn func() (string, error) + httpClient func() (*http.Client, error) + remotesFn func() (ghContext.Remotes, error) + gitConfigClient GitConfigClient + progress progressIndicator baseRefRepo ghrepo.Interface prNumber int @@ -56,23 +61,12 @@ func NewFinder(factory *cmdutil.Factory) PRFinder { } return &finder{ - baseRepoFn: factory.BaseRepo, - branchFn: factory.Branch, - remotesFn: factory.Remotes, - httpClient: factory.HttpClient, - pushDefault: func() (string, error) { - return factory.GitClient.PushDefault(context.Background()) - }, - remotePushDefault: func() (string, error) { - return factory.GitClient.RemotePushDefault(context.Background()) - }, - parsePushRevision: func(branch string) (string, error) { - return factory.GitClient.ParsePushRevision(context.Background(), branch) - }, - progress: factory.IOStreams, - branchConfig: func(s string) (git.BranchConfig, error) { - return factory.GitClient.ReadBranchConfig(context.Background(), s) - }, + baseRepoFn: factory.BaseRepo, + branchFn: factory.Branch, + httpClient: factory.HttpClient, + gitConfigClient: factory.GitClient, + remotesFn: factory.Remotes, + progress: factory.IOStreams, } } @@ -97,32 +91,6 @@ type FindOptions struct { States []string } -// TODO: Does this also need the BaseBranchName? -// PR's are represented by the following: -// headRef -----PR-----> baseRef -// -// A ref is described as "remoteName/branchName", so -// headRepoName/headBranchName -----PR-----> baseRepoName/baseBranchName -type PullRequestRefs struct { - BranchName string - HeadRepo ghrepo.Interface - BaseRepo ghrepo.Interface -} - -func (s *PullRequestRefs) HasHead() bool { - return s.HeadRepo != nil && s.BranchName != "" -} - -// GetPRHeadLabel returns the string that the GitHub API uses to identify the PR. This is -// either just the branch name or, if the PR is originating from a fork, the fork owner -// and the branch name, like :. -func (s *PullRequestRefs) GetPRHeadLabel() string { - if ghrepo.IsSame(s.HeadRepo, s.BaseRepo) { - return s.BranchName - } - return fmt.Sprintf("%s:%s", s.HeadRepo.RepoOwner(), s.BranchName) -} - func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, error) { // If we have a URL, we don't need git stuff if len(opts.Fields) == 0 { @@ -142,7 +110,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err f.baseRefRepo = repo } - var prRefs PullRequestRefs + var prRefs PRFindRefs if opts.Selector == "" { // You must be in a git repo for this case to work currentBranchName, err := f.branchFn() @@ -152,7 +120,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err f.branchName = currentBranchName // Get the branch config for the current branchName - branchConfig, err := f.branchConfig(f.branchName) + branchConfig, err := f.gitConfigClient.ReadBranchConfig(context.Background(), f.branchName) if err != nil { return nil, nil, err } @@ -166,30 +134,19 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err // Determine the PullRequestRefs from config if f.prNumber == 0 { - rems, err := f.remotesFn() - if err != nil { - return nil, nil, err - } - - // Suppressing these errors as we have other means of computing the PullRequestRefs when these fail. - parsedPushRevision, _ := f.parsePushRevision(f.branchName) - - pushDefault, err := f.pushDefault() - if err != nil { - return nil, nil, err - } - - remotePushDefault, err := f.remotePushDefault() - if err != nil { - return nil, nil, err - } - - prRefs, err = ParsePRRefs(f.branchName, branchConfig, parsedPushRevision, pushDefault, remotePushDefault, f.baseRefRepo, rems) + prRefsResolver := NewPullRequestFindRefsResolver( + // We requested the branch config already, so let's cache that + CachedBranchConfigGitConfigClient{ + CachedBranchConfig: branchConfig, + GitConfigClient: f.gitConfigClient, + }, + f.remotesFn, + ) + prRefs, err = prRefsResolver.ResolvePullRequestRefs(f.baseRefRepo, opts.BaseBranch, f.branchName) if err != nil { return nil, nil, err } } - } else if f.prNumber == 0 { // You gave me a selector but I couldn't find a PR number (it wasn't a URL) @@ -204,11 +161,17 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err f.prNumber = prNumber } else { f.branchName = opts.Selector - // We don't expect an error here because parsedPushRevision is empty - prRefs, err = ParsePRRefs(f.branchName, git.BranchConfig{}, "", "", "", f.baseRefRepo, remotes.Remotes{}) + + qualifiedHeadRef, err := ParseQualifiedHeadRef(f.branchName) if err != nil { return nil, nil, err } + + prRefs = PRFindRefs{ + qualifiedHeadRef: qualifiedHeadRef, + baseRepo: f.baseRefRepo, + baseBranchName: o.SomeIfNonZero(opts.BaseBranch), + } } } @@ -259,7 +222,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err return pr, f.baseRefRepo, err } } else { - pr, err = findForBranch(httpClient, f.baseRefRepo, opts.BaseBranch, prRefs.GetPRHeadLabel(), opts.States, fields.ToSlice()) + pr, err = findForRefs(httpClient, prRefs, opts.States, fields.ToSlice()) if err != nil { return pr, f.baseRefRepo, err } @@ -321,72 +284,6 @@ func (f *finder) parseURL(prURL string) (ghrepo.Interface, int, error) { return repo, prNumber, nil } -func ParsePRRefs(currentBranchName string, branchConfig git.BranchConfig, parsedPushRevision string, pushDefault string, remotePushDefault string, baseRefRepo ghrepo.Interface, rems remotes.Remotes) (PullRequestRefs, error) { - prRefs := PullRequestRefs{ - BaseRepo: baseRefRepo, - } - - // If @{push} resolves, then we have all the information we need to determine the head repo - // and branch name. It is of the form /. - if parsedPushRevision != "" { - for _, r := range rems { - // Find the remote who's name matches the push prefix - if strings.HasPrefix(parsedPushRevision, r.Name+"/") { - prRefs.BranchName = strings.TrimPrefix(parsedPushRevision, r.Name+"/") - prRefs.HeadRepo = r.Repo - return prRefs, nil - } - } - - remoteNames := make([]string, len(rems)) - for i, r := range rems { - remoteNames[i] = r.Name - } - return PullRequestRefs{}, fmt.Errorf("no remote for %q found in %q", parsedPushRevision, strings.Join(remoteNames, ", ")) - } - - // We assume the PR's branch name is the same as whatever f.BranchFn() returned earlier - // unless the user has specified push.default = upstream or tracking, then we use the - // branch name from the merge ref. - prRefs.BranchName = currentBranchName - if pushDefault == "upstream" || pushDefault == "tracking" { - prRefs.BranchName = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") - } - - // To get the HeadRepo, we look to the git config. The HeadRepo comes from one of the following, in order of precedence: - // 1. branch..pushRemote - // 2. remote.pushDefault - // 3. branch..remote - if branchConfig.PushRemoteName != "" { - if r, err := rems.FindByName(branchConfig.PushRemoteName); err == nil { - prRefs.HeadRepo = r.Repo - } - } else if branchConfig.PushRemoteURL != nil { - if r, err := ghrepo.FromURL(branchConfig.PushRemoteURL); err == nil { - prRefs.HeadRepo = r - } - } else if remotePushDefault != "" { - if r, err := rems.FindByName(remotePushDefault); err == nil { - prRefs.HeadRepo = r.Repo - } - } else if branchConfig.RemoteName != "" { - if r, err := rems.FindByName(branchConfig.RemoteName); err == nil { - prRefs.HeadRepo = r.Repo - } - } else if branchConfig.RemoteURL != nil { - if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil { - prRefs.HeadRepo = r - } - } - - // The PR merges from a branch in the same repo as the base branch (usually the default branch) - if prRefs.HeadRepo == nil { - prRefs.HeadRepo = baseRefRepo - } - - return prRefs, nil -} - func findByNumber(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.PullRequest, error) { type response struct { Repository struct { @@ -417,7 +314,7 @@ func findByNumber(httpClient *http.Client, repo ghrepo.Interface, number int, fi return &resp.Repository.PullRequest, nil } -func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, headBranchWithOwnerIfFork string, stateFilters, fields []string) (*api.PullRequest, error) { +func findForRefs(httpClient *http.Client, prRefs PRFindRefs, stateFilters, fields []string) (*api.PullRequest, error) { type response struct { Repository struct { PullRequests struct { @@ -444,21 +341,16 @@ func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, h } }`, api.PullRequestGraphQL(fieldSet.ToSlice())) - branchWithoutOwner := headBranchWithOwnerIfFork - if idx := strings.Index(headBranchWithOwnerIfFork, ":"); idx >= 0 { - branchWithoutOwner = headBranchWithOwnerIfFork[idx+1:] - } - variables := map[string]interface{}{ - "owner": repo.RepoOwner(), - "repo": repo.RepoName(), - "headRefName": branchWithoutOwner, + "owner": prRefs.BaseRepo().RepoOwner(), + "repo": prRefs.BaseRepo().RepoName(), + "headRefName": prRefs.UnqualifiedHeadRef(), "states": stateFilters, } var resp response client := api.NewClientFromHTTP(httpClient) - err := client.GraphQL(repo.RepoHost(), query, variables, &resp) + err := client.GraphQL(prRefs.BaseRepo().RepoHost(), query, variables, &resp) if err != nil { return nil, err } @@ -469,17 +361,15 @@ func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, h }) for _, pr := range prs { - headBranchMatches := pr.HeadLabel() == headBranchWithOwnerIfFork - baseBranchEmptyOrMatches := baseBranch == "" || pr.BaseRefName == baseBranch // When the head is the default branch, it doesn't really make sense to show merged or closed PRs. // https://github.com/cli/cli/issues/4263 - isNotClosedOrMergedWhenHeadIsDefault := pr.State == "OPEN" || resp.Repository.DefaultBranchRef.Name != headBranchWithOwnerIfFork - if headBranchMatches && baseBranchEmptyOrMatches && isNotClosedOrMergedWhenHeadIsDefault { + isNotClosedOrMergedWhenHeadIsDefault := pr.State == "OPEN" || resp.Repository.DefaultBranchRef.Name != prRefs.QualifiedHeadRef() + if prRefs.Matches(pr.BaseRefName, pr.HeadLabel()) && isNotClosedOrMergedWhenHeadIsDefault { return &pr, nil } } - return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", headBranchWithOwnerIfFork)} + return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", prRefs.QualifiedHeadRef())} } func preloadPrReviews(httpClient *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error { diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 3349197e2..e1aae16b1 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -1,46 +1,41 @@ package shared import ( + "context" "errors" - "fmt" "net/http" "net/url" "testing" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type args struct { - baseRepoFn func() (ghrepo.Interface, error) - branchFn func() (string, error) - branchConfig func(string) (git.BranchConfig, error) - pushDefault func() (string, error) - remotePushDefault func() (string, error) - parsePushRevision func(string) (string, error) - selector string - fields []string - baseBranch string + baseRepoFn func() (ghrepo.Interface, error) + branchFn func() (string, error) + gitConfigClient stubGitConfigClient + selector string + fields []string + baseBranch string } func TestFind(t *testing.T) { - // TODO: Abstract these out meaningfully for reuse in parsePRRefs tests originOwnerUrl, err := url.Parse("https://github.com/ORIGINOWNER/REPO.git") if err != nil { t.Fatal(err) } - remoteOrigin := context.Remote{ + remoteOrigin := ghContext.Remote{ Remote: &git.Remote{ Name: "origin", FetchURL: originOwnerUrl, }, Repo: ghrepo.New("ORIGINOWNER", "REPO"), } - remoteOther := context.Remote{ + remoteOther := ghContext.Remote{ Remote: &git.Remote{ Name: "other", FetchURL: originOwnerUrl, @@ -52,7 +47,7 @@ func TestFind(t *testing.T) { if err != nil { t.Fatal(err) } - remoteUpstream := context.Remote{ + remoteUpstream := ghContext.Remote{ Remote: &git.Remote{ Name: "upstream", FetchURL: upstreamOwnerUrl, @@ -77,7 +72,6 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -99,12 +93,14 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{ - PushRemoteName: remoteOrigin.Remote.Name, - }, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + PushRemoteName: remoteOrigin.Remote.Name, + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -134,9 +130,11 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + }, }, wantErr: true, }, @@ -157,9 +155,11 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + }, }, httpStub: nil, wantPR: 13, @@ -174,9 +174,11 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -197,9 +199,11 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -223,15 +227,17 @@ func TestFind(t *testing.T) { ExitCode: 128, } }, - branchConfig: stubBranchConfig(git.BranchConfig{}, &git.GitError{ - Stderr: "fatal: branchConfig error", - ExitCode: 128, - }), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", &git.GitError{ - Stderr: "fatal: remotePushDefault error", - ExitCode: 128, - }), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, &git.GitError{ + Stderr: "fatal: branchConfig error", + ExitCode: 128, + }), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", &git.GitError{ + Stderr: "fatal: remotePushDefault error", + ExitCode: 128, + }), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -252,10 +258,12 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - parsePushRevision: stubParsedPushRevision("", nil), - remotePushDefault: stubRemotePushDefault("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + remotePushDefaultFn: stubRemotePushDefault("", nil), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -296,10 +304,12 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -339,10 +349,12 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -374,10 +386,12 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -423,13 +437,15 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{ - MergeRef: "refs/heads/blue-upstream-berries", - PushRemoteName: "upstream", - }, nil), - pushDefault: stubPushDefault("upstream", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + MergeRef: "refs/heads/blue-upstream-berries", + PushRemoteName: "upstream", + }, nil), + pushDefaultFn: stubPushDefault("upstream", nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -463,13 +479,15 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{ - MergeRef: "refs/heads/blue-upstream-berries", - PushRemoteURL: remoteUpstream.Remote.FetchURL, - }, nil), - pushDefault: stubPushDefault("upstream", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + MergeRef: "refs/heads/blue-upstream-berries", + PushRemoteURL: remoteUpstream.Remote.FetchURL, + }, nil), + pushDefaultFn: stubPushDefault("upstream", nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -499,10 +517,12 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), - parsePushRevision: stubParsedPushRevision("other/blueberries", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{Remote: "other", Branch: "blueberries"}, nil), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -534,9 +554,11 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{ - MergeRef: "refs/pull/13/head", - }, nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + MergeRef: "refs/pull/13/head", + }, nil), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -559,11 +581,13 @@ func TestFind(t *testing.T) { branchFn: func() (string, error) { return "blueberries", nil }, - branchConfig: stubBranchConfig(git.BranchConfig{ - MergeRef: "refs/pull/13/head", - }, nil), - pushDefault: stubPushDefault("simple", nil), - remotePushDefault: stubRemotePushDefault("", nil), + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{ + MergeRef: "refs/pull/13/head", + }, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + }, }, httpStub: func(r *httpmock.Registry) { r.Register( @@ -575,32 +599,32 @@ func TestFind(t *testing.T) { r.Register( httpmock.GraphQL(`query PullRequestProjectItems\b`), httpmock.GraphQLQuery(`{ - "data": { - "repository": { - "pullRequest": { - "projectItems": { - "nodes": [ - { - "id": "PVTI_lADOB-vozM4AVk16zgK6U50", - "project": { - "id": "PVT_kwDOB-vozM4AVk16", - "title": "Test Project" - }, - "status": { - "optionId": "47fc9ee4", - "name": "In Progress" - } - } - ], - "pageInfo": { - "hasNextPage": false, - "endCursor": "MQ" - } - } - } - } - } - }`, + "data": { + "repository": { + "pullRequest": { + "projectItems": { + "nodes": [ + { + "id": "PVTI_lADOB-vozM4AVk16zgK6U50", + "project": { + "id": "PVT_kwDOB-vozM4AVk16", + "title": "Test Project" + }, + "status": { + "optionId": "47fc9ee4", + "name": "In Progress" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "MQ" + } + } + } + } + } + }`, func(query string, inputs map[string]interface{}) { require.Equal(t, float64(13), inputs["number"]) require.Equal(t, "OWNER", inputs["owner"]) @@ -624,13 +648,10 @@ func TestFind(t *testing.T) { httpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - baseRepoFn: tt.args.baseRepoFn, - branchFn: tt.args.branchFn, - branchConfig: tt.args.branchConfig, - pushDefault: tt.args.pushDefault, - remotePushDefault: tt.args.remotePushDefault, - parsePushRevision: tt.args.parsePushRevision, - remotesFn: stubRemotes(context.Remotes{ + baseRepoFn: tt.args.baseRepoFn, + branchFn: tt.args.branchFn, + gitConfigClient: tt.args.gitConfigClient, + remotesFn: stubRemotes(ghContext.Remotes{ &remoteOrigin, &remoteOther, &remoteUpstream, @@ -667,366 +688,14 @@ func TestFind(t *testing.T) { } } -func TestParsePRRefs(t *testing.T) { - originOwnerUrl, err := url.Parse("https://github.com/ORIGINOWNER/REPO.git") - if err != nil { - t.Fatal(err) - } - remoteOrigin := context.Remote{ - Remote: &git.Remote{ - Name: "origin", - FetchURL: originOwnerUrl, - }, - Repo: ghrepo.New("ORIGINOWNER", "REPO"), - } - remoteOther := context.Remote{ - Remote: &git.Remote{ - Name: "other", - FetchURL: originOwnerUrl, - }, - Repo: ghrepo.New("ORIGINOWNER", "REPO"), - } - - upstreamOwnerUrl, err := url.Parse("https://github.com/UPSTREAMOWNER/REPO.git") - if err != nil { - t.Fatal(err) - } - remoteUpstream := context.Remote{ - Remote: &git.Remote{ - Name: "upstream", - FetchURL: upstreamOwnerUrl, - }, - Repo: ghrepo.New("UPSTREAMOWNER", "REPO"), - } - - tests := []struct { - name string - branchConfig git.BranchConfig - pushDefault string - parsedPushRevision string - remotePushDefault string - currentBranchName string - baseRefRepo ghrepo.Interface - rems context.Remotes - wantPRRefs PullRequestRefs - wantErr error - }{ - { - name: "When the branch is called 'blueberries' with an empty branch config, it returns the correct PullRequestRefs", - branchConfig: git.BranchConfig{}, - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteOrigin.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the branch is called 'otherBranch' with an empty branch config, it returns the correct PullRequestRefs", - branchConfig: git.BranchConfig{}, - currentBranchName: "otherBranch", - baseRefRepo: remoteOrigin.Repo, - wantPRRefs: PullRequestRefs{ - BranchName: "otherBranch", - HeadRepo: remoteOrigin.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the branch name doesn't match the branch name in BranchConfig.Push, it returns the BranchConfig.Push branch name", - parsedPushRevision: "origin/pushBranch", - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "pushBranch", - HeadRepo: remoteOrigin.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the push revision doesn't match a remote, it returns an error", - parsedPushRevision: "origin/differentPushBranch", - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteUpstream, - &remoteOther, - }, - wantPRRefs: PullRequestRefs{}, - wantErr: fmt.Errorf("no remote for %q found in %q", "origin/differentPushBranch", "upstream, other"), - }, - { - name: "When the branch name doesn't match a different branch name in BranchConfig.Push and the remote isn't 'origin', it returns the BranchConfig.Push branch name", - parsedPushRevision: "other/pushBranch", - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOther, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "pushBranch", - HeadRepo: remoteOther.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the push remote is the same as the baseRepo, it returns the baseRepo as the PullRequestRefs HeadRepo", - branchConfig: git.BranchConfig{ - PushRemoteName: remoteOrigin.Remote.Name, - }, - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteOrigin.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the push remote is different from the baseRepo, it returns the push remote repo as the PullRequestRefs HeadRepo", - branchConfig: git.BranchConfig{ - PushRemoteName: remoteOrigin.Remote.Name, - }, - currentBranchName: "blueberries", - baseRefRepo: remoteUpstream.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteOrigin.Repo, - BaseRepo: remoteUpstream.Repo, - }, - wantErr: nil, - }, - { - name: "When the push remote defined by a URL and the baseRepo is different from the push remote, it returns the push remote repo as the PullRequestRefs HeadRepo", - branchConfig: git.BranchConfig{ - PushRemoteURL: remoteOrigin.Remote.FetchURL, - }, - currentBranchName: "blueberries", - baseRefRepo: remoteUpstream.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteOrigin.Repo, - BaseRepo: remoteUpstream.Repo, - }, - wantErr: nil, - }, - { - name: "When the push remote and merge ref are configured to a different repo and push.default = upstream, it should return the branch name from the other repo", - branchConfig: git.BranchConfig{ - PushRemoteName: remoteUpstream.Remote.Name, - MergeRef: "refs/heads/blue-upstream-berries", - }, - pushDefault: "upstream", - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blue-upstream-berries", - HeadRepo: remoteUpstream.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the push remote and merge ref are configured to a different repo and push.default = tracking, it should return the branch name from the other repo", - branchConfig: git.BranchConfig{ - PushRemoteName: remoteUpstream.Remote.Name, - MergeRef: "refs/heads/blue-upstream-berries", - }, - pushDefault: "tracking", - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blue-upstream-berries", - HeadRepo: remoteUpstream.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When remote.pushDefault is set, it returns the correct PullRequestRefs", - branchConfig: git.BranchConfig{}, - remotePushDefault: remoteUpstream.Remote.Name, - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteUpstream.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the remote name is set on the branch, it returns the correct PullRequestRefs", - branchConfig: git.BranchConfig{ - RemoteName: remoteUpstream.Remote.Name, - }, - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteUpstream.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - { - name: "When the remote URL is set on the branch, it returns the correct PullRequestRefs", - branchConfig: git.BranchConfig{ - RemoteURL: remoteUpstream.Remote.FetchURL, - }, - currentBranchName: "blueberries", - baseRefRepo: remoteOrigin.Repo, - rems: context.Remotes{ - &remoteOrigin, - &remoteUpstream, - }, - wantPRRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: remoteUpstream.Repo, - BaseRepo: remoteOrigin.Repo, - }, - wantErr: nil, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - prRefs, err := ParsePRRefs(tt.currentBranchName, tt.branchConfig, tt.parsedPushRevision, tt.pushDefault, tt.remotePushDefault, tt.baseRefRepo, tt.rems) - if tt.wantErr != nil { - require.Equal(t, tt.wantErr, err) - } else { - require.NoError(t, err) - } - require.Equal(t, tt.wantPRRefs, prRefs) - }) - } -} - -func TestPRRefs_GetPRHeadLabel(t *testing.T) { - originRepo := ghrepo.New("ORIGINOWNER", "REPO") - upstreamRepo := ghrepo.New("UPSTREAMOWNER", "REPO") - tests := []struct { - name string - prRefs PullRequestRefs - want string - }{ - { - name: "When the HeadRepo and BaseRepo match, it returns the branch name", - prRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: originRepo, - BaseRepo: originRepo, - }, - want: "blueberries", - }, - { - name: "When the HeadRepo and BaseRepo do not match, it returns the prepended HeadRepo owner to the branch name", - prRefs: PullRequestRefs{ - BranchName: "blueberries", - HeadRepo: originRepo, - BaseRepo: upstreamRepo, - }, - want: "ORIGINOWNER:blueberries", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, tt.prRefs.GetPRHeadLabel()) - }) - } -} - -func TestPullRequestRefs_HasHead(t *testing.T) { - tests := []struct { - name string - prRefs PullRequestRefs - want bool - }{ - { - name: "HeadRepo is nil and BranchName is empty, return false", - prRefs: PullRequestRefs{ - HeadRepo: nil, - BranchName: "", - }, - want: false, - }, - { - name: "HeadRepo is not nil and BranchName is empty, return false", - prRefs: PullRequestRefs{ - HeadRepo: ghrepo.New("ORIGINOWNER", "REPO"), - BranchName: "", - }, - want: false, - }, - { - name: "HeadRepo is nil and BranchName is not empty, return false", - prRefs: PullRequestRefs{ - HeadRepo: nil, - BranchName: "feature-branch", - }, - want: false, - }, - { - name: "HeadRepo is not nil and BranchName is not empty, return true", - prRefs: PullRequestRefs{ - HeadRepo: ghrepo.New("ORIGINOWNER", "REPO"), - BranchName: "feature-branch", - }, - want: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - require.Equal(t, tt.want, tt.prRefs.HasHead()) - }) - } -} - -func stubBranchConfig(branchConfig git.BranchConfig, err error) func(string) (git.BranchConfig, error) { - return func(branch string) (git.BranchConfig, error) { +func stubBranchConfig(branchConfig git.BranchConfig, err error) func(context.Context, string) (git.BranchConfig, error) { + return func(_ context.Context, branch string) (git.BranchConfig, error) { return branchConfig, err } } -func stubRemotes(remotes context.Remotes, err error) func() (context.Remotes, error) { - return func() (context.Remotes, error) { +func stubRemotes(remotes ghContext.Remotes, err error) func() (ghContext.Remotes, error) { + return func() (ghContext.Remotes, error) { return remotes, err } } @@ -1037,20 +706,55 @@ func stubBaseRepoFn(baseRepo ghrepo.Interface, err error) func() (ghrepo.Interfa } } -func stubPushDefault(pushDefault string, err error) func() (string, error) { - return func() (string, error) { +func stubPushDefault(pushDefault git.PushDefault, err error) func(context.Context) (git.PushDefault, error) { + return func(_ context.Context) (git.PushDefault, error) { return pushDefault, err } } -func stubRemotePushDefault(remotePushDefault string, err error) func() (string, error) { - return func() (string, error) { +func stubRemotePushDefault(remotePushDefault string, err error) func(context.Context) (string, error) { + return func(_ context.Context) (string, error) { return remotePushDefault, err } } -func stubParsedPushRevision(parsedPushRevision string, err error) func(string) (string, error) { - return func(_ string) (string, error) { +func stubPushRevision(parsedPushRevision git.RemoteTrackingRef, err error) func(context.Context, string) (git.RemoteTrackingRef, error) { + return func(_ context.Context, _ string) (git.RemoteTrackingRef, error) { return parsedPushRevision, err } } + +type stubGitConfigClient struct { + readBranchConfigFn func(ctx context.Context, branchName string) (git.BranchConfig, error) + pushDefaultFn func(ctx context.Context) (git.PushDefault, error) + remotePushDefaultFn func(ctx context.Context) (string, error) + pushRevisionFn func(ctx context.Context, branchName string) (git.RemoteTrackingRef, error) +} + +func (s stubGitConfigClient) ReadBranchConfig(ctx context.Context, branchName string) (git.BranchConfig, error) { + if s.readBranchConfigFn == nil { + panic("unexpected call to ReadBranchConfig") + } + return s.readBranchConfigFn(ctx, branchName) +} + +func (s stubGitConfigClient) PushDefault(ctx context.Context) (git.PushDefault, error) { + if s.pushDefaultFn == nil { + panic("unexpected call to PushDefault") + } + return s.pushDefaultFn(ctx) +} + +func (s stubGitConfigClient) RemotePushDefault(ctx context.Context) (string, error) { + if s.remotePushDefaultFn == nil { + panic("unexpected call to RemotePushDefault") + } + return s.remotePushDefaultFn(ctx) +} + +func (s stubGitConfigClient) PushRevision(ctx context.Context, branchName string) (git.RemoteTrackingRef, error) { + if s.pushRevisionFn == nil { + panic("unexpected call to PushRevision") + } + return s.pushRevisionFn(ctx, branchName) +} diff --git a/pkg/cmd/pr/shared/git_cached_config_client.go b/pkg/cmd/pr/shared/git_cached_config_client.go new file mode 100644 index 000000000..aea25abee --- /dev/null +++ b/pkg/cmd/pr/shared/git_cached_config_client.go @@ -0,0 +1,18 @@ +package shared + +import ( + "context" + + "github.com/cli/cli/v2/git" +) + +var _ GitConfigClient = &CachedBranchConfigGitConfigClient{} + +type CachedBranchConfigGitConfigClient struct { + CachedBranchConfig git.BranchConfig + GitConfigClient +} + +func (c CachedBranchConfigGitConfigClient) ReadBranchConfig(ctx context.Context, branchName string) (git.BranchConfig, error) { + return c.CachedBranchConfig, nil +} diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index eb120e5a7..60202594f 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -102,43 +102,34 @@ func statusRun(opts *StatusOptions) error { return fmt.Errorf("could not query for pull request for current branch: %w", err) } - branchConfig, err := opts.GitClient.ReadBranchConfig(ctx, currentBranchName) - if err != nil { - return err - } - // Determine if the branch is configured to merge to a special PR ref - prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) - if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { - currentPRNumber, _ = strconv.Atoi(m[1]) - } - - if currentPRNumber == 0 { - remotes, err := opts.Remotes() + if !errors.Is(err, git.ErrNotOnAnyBranch) { + branchConfig, err := opts.GitClient.ReadBranchConfig(ctx, currentBranchName) if err != nil { return err } - // Suppressing these errors as we have other means of computing the PullRequestRefs when these fail. - parsedPushRevision, _ := opts.GitClient.ParsePushRevision(ctx, currentBranchName) - - remotePushDefault, err := opts.GitClient.RemotePushDefault(ctx) - if err != nil { - return err + // Determine if the branch is configured to merge to a special PR ref + prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`) + if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil { + currentPRNumber, _ = strconv.Atoi(m[1]) } - pushDefault, err := opts.GitClient.PushDefault(ctx) - if err != nil { - return err - } + if currentPRNumber == 0 { + prRefsResolver := shared.NewPullRequestFindRefsResolver( + // We requested the branch config already, so let's cache that + shared.CachedBranchConfigGitConfigClient{ + CachedBranchConfig: branchConfig, + GitConfigClient: opts.GitClient, + }, + opts.Remotes, + ) - prRefs, err := shared.ParsePRRefs(currentBranchName, branchConfig, parsedPushRevision, pushDefault, remotePushDefault, baseRefRepo, remotes) - if err != nil { - return err - } - currentHeadRefBranchName = prRefs.BranchName - } + prRefs, err := prRefsResolver.ResolvePullRequestRefs(baseRefRepo, "", currentBranchName) + if err != nil { + return err + } - if err != nil { - return fmt.Errorf("could not query for pull request for current branch: %w", err) + currentHeadRefBranchName = prRefs.QualifiedHeadRef() + } } } diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go index c55604c28..41c01e915 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -98,10 +98,10 @@ func TestPRStatus(t *testing.T) { // stub successful git commands rs, cleanup := run.Stub() defer cleanup(t) + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -133,8 +133,8 @@ func TestPRStatus_reviewsAndChecks(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -166,8 +166,8 @@ func TestPRStatus_reviewsAndChecksWithStatesByCount(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommandWithDetector(http, "blueberries", true, "", &fd.EnabledDetectorMock{}) if err != nil { @@ -198,8 +198,8 @@ func TestPRStatus_currentBranch_showTheMostRecentPR(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -234,8 +234,8 @@ func TestPRStatus_currentBranch_defaultBranch(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -276,8 +276,8 @@ func TestPRStatus_currentBranch_Closed(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -301,8 +301,8 @@ func TestPRStatus_currentBranch_Closed_defaultBranch(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -326,8 +326,8 @@ func TestPRStatus_currentBranch_Merged(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -351,8 +351,8 @@ func TestPRStatus_currentBranch_Merged_defaultBranch(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -376,8 +376,8 @@ func TestPRStatus_blankSlate(t *testing.T) { defer cleanup(t) rs.Register(`git config --get-regexp \^branch\\.`, 0, "") rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") + rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "") + rs.Register(`git config push.default`, 1, "") output, err := runCommand(http, "blueberries", true, "") if err != nil { @@ -432,14 +432,6 @@ func TestPRStatus_detachedHead(t *testing.T) { defer http.Verify(t) http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`)) - // stub successful git command - rs, cleanup := run.Stub() - defer cleanup(t) - rs.Register(`git config --get-regexp \^branch\\.`, 0, "") - rs.Register(`git config remote.pushDefault`, 0, "") - rs.Register(`git rev-parse --abbrev-ref @{push}`, 0, "") - rs.Register(`git config push.default`, 0, "") - output, err := runCommand(http, "", true, "") if err != nil { t.Errorf("error running command `pr status`: %v", err) diff --git a/pkg/httpmock/registry.go b/pkg/httpmock/registry.go index 387d0fc95..51aa5a898 100644 --- a/pkg/httpmock/registry.go +++ b/pkg/httpmock/registry.go @@ -3,6 +3,8 @@ package httpmock import ( "fmt" "net/http" + "runtime/debug" + "strings" "sync" "testing" @@ -23,6 +25,7 @@ type Registry struct { func (r *Registry) Register(m Matcher, resp Responder) { r.stubs = append(r.stubs, &Stub{ + Stack: string(debug.Stack()), Matcher: m, Responder: resp, }) @@ -46,17 +49,24 @@ type Testing interface { } func (r *Registry) Verify(t Testing) { - n := 0 + var unmatchedStubStacks []string for _, s := range r.stubs { if !s.matched && !s.exclude { - n++ + unmatchedStubStacks = append(unmatchedStubStacks, s.Stack) } } - if n > 0 { + if len(unmatchedStubStacks) > 0 { t.Helper() - // NOTE: stubs offer no useful reflection, so we can't print details + stacks := strings.Builder{} + for i, stack := range unmatchedStubStacks { + stacks.WriteString(fmt.Sprintf("Stub %d:\n", i+1)) + stacks.WriteString(fmt.Sprintf("\t%s", stack)) + if stack != unmatchedStubStacks[len(unmatchedStubStacks)-1] { + stacks.WriteString("\n") + } + } // about dead stubs and what they were trying to match - t.Errorf("%d unmatched HTTP stubs", n) + t.Errorf("%d HTTP stubs unmatched, stacks:\n%s", len(unmatchedStubStacks), stacks.String()) } } @@ -84,7 +94,7 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) { if stub == nil { r.mu.Unlock() - return nil, fmt.Errorf("no registered stubs matched %v", req) + return nil, fmt.Errorf("no registered HTTP stubs matched %v", req) } r.Requests = append(r.Requests, req) diff --git a/pkg/httpmock/stub.go b/pkg/httpmock/stub.go index 4e61d12f4..745c12417 100644 --- a/pkg/httpmock/stub.go +++ b/pkg/httpmock/stub.go @@ -15,6 +15,7 @@ type Matcher func(req *http.Request) bool type Responder func(req *http.Request) (*http.Response, error) type Stub struct { + Stack string matched bool Matcher Matcher Responder Responder diff --git a/pkg/option/option.go b/pkg/option/option.go index 8d3b70f3f..caf26dd0b 100644 --- a/pkg/option/option.go +++ b/pkg/option/option.go @@ -46,6 +46,15 @@ func None[T any]() Option[T] { return Option[T]{} } +func SomeIfNonZero[T comparable](value T) Option[T] { + // value is a zero value then return a None + var zero T + if value == zero { + return None[T]() + } + return Some(value) +} + // String implements the [fmt.Stringer] interface. func (o Option[T]) String() string { if o.present { From becd936e7b904dfc04ba96089e5318a07879870d Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Tue, 15 Apr 2025 17:13:43 -0400 Subject: [PATCH 141/147] Update searcher tests This commit moves the remaining searcher tests from using JSON marshaled types to using JSON responses for consistency. There appears to be a weird JSON marshaling error with search.Repository that does not map `Name` field in the process. Additionally, the test scenarios around pulling multiple pages beneath the total results have been updated to demonstrate that the REST API returns full pages in both of these cases, which is below the total number of results. --- pkg/search/searcher_test.go | 116 +++++++++++++++++++++++------------- 1 file changed, 74 insertions(+), 42 deletions(-) diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go index 503751e3e..e893c9a3b 100644 --- a/pkg/search/searcher_test.go +++ b/pkg/search/searcher_test.go @@ -48,10 +48,14 @@ func TestSearcherCode(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "search/code", values), - httpmock.JSONResponse(CodeResult{ - IncompleteResults: false, - Items: []Code{{Name: "file.go"}}, - Total: 1, + httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 1, + "items": []interface{}{ + map[string]interface{}{ + "name": "file.go", + }, + }, }), ) }, @@ -68,10 +72,14 @@ func TestSearcherCode(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "api/v3/search/code", values), - httpmock.JSONResponse(CodeResult{ - IncompleteResults: false, - Items: []Code{{Name: "file.go"}}, - Total: 1, + httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 1, + "items": []interface{}{ + map[string]interface{}{ + "name": "file.go", + }, + }, }), ) }, @@ -89,7 +97,11 @@ func TestSearcherCode(t *testing.T) { firstRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, "total_count": 2, - "items": []Code{{Name: "file.go"}}, + "items": []interface{}{ + map[string]interface{}{ + "name": "file.go", + }, + }, }) firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/code", url.Values{ @@ -100,14 +112,18 @@ func TestSearcherCode(t *testing.T) { secondRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, "total_count": 2, - "items": []Code{{Name: "file2.go"}}, + "items": []interface{}{ + map[string]interface{}{ + "name": "file2.go", + }, + }, }) reg.Register(firstReq, firstRes) reg.Register(secondReq, secondRes) }, }, { - name: "collects results for limit above one page", + name: "collect full and partial pages under total number of matching search results", query: Query{ Keywords: []string{"keyword"}, Kind: "code", @@ -123,7 +139,7 @@ func TestSearcherCode(t *testing.T) { Name: fmt.Sprintf("name%d.go", i), } }), - Total: 110, + Total: 287, }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/code", url.Values{ @@ -133,7 +149,7 @@ func TestSearcherCode(t *testing.T) { }) firstRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 110, + "total_count": 287, "items": initialize(0, 100, func(i int) interface{} { return map[string]interface{}{ "name": fmt.Sprintf("name%d.go", i), @@ -148,8 +164,8 @@ func TestSearcherCode(t *testing.T) { }) secondRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 110, - "items": initialize(100, 110, func(i int) interface{} { + "total_count": 287, + "items": initialize(100, 200, func(i int) interface{} { return map[string]interface{}{ "name": fmt.Sprintf("name%d.go", i), } @@ -253,10 +269,14 @@ func TestSearcherCommits(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.QueryMatcher("GET", "search/commits", values), - httpmock.JSONResponse(CommitsResult{ - IncompleteResults: false, - Items: []Commit{{Sha: "abc"}}, - Total: 1, + httpmock.JSONResponse(map[string]interface{}{ + "incomplete_results": false, + "total_count": 1, + "items": []interface{}{ + map[string]interface{}{ + "sha": "abc", + }, + }, }), ) }, @@ -276,7 +296,11 @@ func TestSearcherCommits(t *testing.T) { httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, "total_count": 1, - "items": []Commit{{Sha: "abc"}}, + "items": []interface{}{ + map[string]interface{}{ + "sha": "abc", + }, + }, }), ) }, @@ -294,7 +318,11 @@ func TestSearcherCommits(t *testing.T) { firstRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, "total_count": 2, - "items": []Commit{{Sha: "abc"}}, + "items": []interface{}{ + map[string]interface{}{ + "sha": "abc", + }, + }, }) firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ @@ -307,14 +335,18 @@ func TestSearcherCommits(t *testing.T) { secondRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, "total_count": 2, - "items": []Commit{{Sha: "def"}}, + "items": []interface{}{ + map[string]interface{}{ + "sha": "def", + }, + }, }) reg.Register(firstReq, firstRes) reg.Register(secondReq, secondRes) }, }, { - name: "collects results for limit above one page", + name: "collect full and partial pages under total number of matching search results", query: Query{ Keywords: []string{"keyword"}, Kind: "commits", @@ -333,7 +365,7 @@ func TestSearcherCommits(t *testing.T) { Sha: strconv.Itoa(i), } }), - Total: 110, + Total: 287, }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{ @@ -345,10 +377,10 @@ func TestSearcherCommits(t *testing.T) { }) firstRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 110, - "items": initialize(0, 100, func(i int) Commit { - return Commit{ - Sha: strconv.Itoa(i), + "total_count": 287, + "items": initialize(0, 100, func(i int) map[string]interface{} { + return map[string]interface{}{ + "sha": strconv.Itoa(i), } }), }) @@ -362,10 +394,10 @@ func TestSearcherCommits(t *testing.T) { }) secondRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 110, - "items": initialize(100, 110, func(i int) Commit { - return Commit{ - Sha: strconv.Itoa(i), + "total_count": 287, + "items": initialize(100, 200, func(i int) map[string]interface{} { + return map[string]interface{}{ + "sha": strconv.Itoa(i), } }), }) @@ -544,7 +576,7 @@ func TestSearcherRepositories(t *testing.T) { }, }, { - name: "collects results for limit above one page", + name: "collect full and partial pages under total number of matching search results", query: Query{ Keywords: []string{"keyword"}, Kind: "repositories", @@ -563,7 +595,7 @@ func TestSearcherRepositories(t *testing.T) { Name: fmt.Sprintf("name%d", i), } }), - Total: 110, + Total: 287, }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{ @@ -575,7 +607,7 @@ func TestSearcherRepositories(t *testing.T) { }) firstRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 110, + "total_count": 287, "items": initialize(0, 100, func(i int) interface{} { return map[string]interface{}{ "name": fmt.Sprintf("name%d", i), @@ -592,8 +624,8 @@ func TestSearcherRepositories(t *testing.T) { }) secondRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 110, - "items": initialize(100, 110, func(i int) interface{} { + "total_count": 287, + "items": initialize(100, 200, func(i int) interface{} { return map[string]interface{}{ "name": fmt.Sprintf("name%d", i), } @@ -774,7 +806,7 @@ func TestSearcherIssues(t *testing.T) { }, }, { - name: "collects results for limit above one page", + name: "collect full and partial pages under total number of matching search results", query: Query{ Keywords: []string{"keyword"}, Kind: "issues", @@ -793,7 +825,7 @@ func TestSearcherIssues(t *testing.T) { Number: i, } }), - Total: 110, + Total: 287, }, httpStubs: func(reg *httpmock.Registry) { firstReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{ @@ -805,7 +837,7 @@ func TestSearcherIssues(t *testing.T) { }) firstRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 110, + "total_count": 287, "items": initialize(0, 100, func(i int) interface{} { return map[string]interface{}{ "number": i, @@ -822,8 +854,8 @@ func TestSearcherIssues(t *testing.T) { }) secondRes := httpmock.JSONResponse(map[string]interface{}{ "incomplete_results": false, - "total_count": 110, - "items": initialize(100, 110, func(i int) interface{} { + "total_count": 287, + "items": initialize(100, 200, func(i int) interface{} { return map[string]interface{}{ "number": i, } From 290e78c904dacc0ef7df727b13c138be251781ae Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 16 Apr 2025 11:05:36 -0600 Subject: [PATCH 142/147] test(iostreams): update test description --- pkg/iostreams/iostreams_progress_indicator_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/iostreams/iostreams_progress_indicator_test.go b/pkg/iostreams/iostreams_progress_indicator_test.go index 42eb5e56a..60d0ece91 100644 --- a/pkg/iostreams/iostreams_progress_indicator_test.go +++ b/pkg/iostreams/iostreams_progress_indicator_test.go @@ -124,7 +124,7 @@ func TestStartProgressIndicatorWithLabel(t *testing.T) { } }) - t.Run("multiple indicators with GH_SPINNER_DISABLED shows current label", func(t *testing.T) { + t.Run("multiple calls to start progress indicator with GH_SPINNER_DISABLED prints additional labels", func(t *testing.T) { console := newTestVirtualTerminal(t) io := newTestIOStreams(t, console, true) progressIndicatorLabel1 := "downloading happiness" From 056d292f2635395bdbdc79e9c95fee84bae61724 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 16 Apr 2025 11:13:58 -0600 Subject: [PATCH 143/147] doc(iostreams): comment behavior of textual progress indicator --- pkg/iostreams/iostreams.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index cfc0da170..ba2cc6b50 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -292,6 +292,10 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) { } if s.spinnerDisabled { + // If the spinner is disabled, simply print a + // textual progress indicator and return. + // This means that s.ProgressIndicator will be nil. + // See also: the comment on StopProgressIndicator() s.startTextualProgressIndicator(label) return } @@ -339,6 +343,9 @@ func (s *IOStreams) startTextualProgressIndicator(label string) { fmt.Fprintf(s.ErrOut, "%s%s", s.ColorScheme().Cyan(label), "\n") } +// StopProgressIndicator stops the progress indicator if it is running. +// Note that a textual progess indicator does not create a progress indicator, +// so this method is a no-op in that case. func (s *IOStreams) StopProgressIndicator() { s.progressIndicatorMu.Lock() defer s.progressIndicatorMu.Unlock() From 1dbb01a1d1d0117f75aeb1ae011bbe53c92969bf Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 16 Apr 2025 11:17:23 -0600 Subject: [PATCH 144/147] doc(help): add documentation for GH_SPINNER_DISABLED --- pkg/cmd/root/help_topic.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index b85d64ca3..4b692777c 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -114,6 +114,9 @@ var HelpTopics = []helpTopic{ %[1]sGH_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are more compatible with speech synthesis and braille screen readers. + + %[1]sGH_SPINNER_DISABLED%[1]s: set to a truthy value to replace the spinner animation with + a textual progress indicator. `, "`"), }, { From dabb29bd361dc29a80b2f666271a28cc0a29f599 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 16 Apr 2025 11:21:39 -0600 Subject: [PATCH 145/147] test(factory): remove needless nil check --- pkg/cmd/factory/default_test.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index aff833d4c..5036a1dc1 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -470,10 +470,8 @@ func Test_ioStreams_spinnerDisabled(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.env != nil { - for k, v := range tt.env { - t.Setenv(k, v) - } + for k, v := range tt.env { + t.Setenv(k, v) } f := New("1") io := ioStreams(f) From ba390db71fe16e86aca1bb0fd5c6e0d8edd55735 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Wed, 16 Apr 2025 14:22:29 -0400 Subject: [PATCH 146/147] PR feedback - update local variables to communicate what they are - added docblock explaining search results populated --- pkg/search/searcher.go | 57 ++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index 4d2154a4a..d1f86fdcd 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -65,7 +65,6 @@ func (s searcher) Code(query Query) (CodeResult, error) { var resp *http.Response var err error - toRetrieve := query.Limit // We will request either the query limit if it's less than 1 page, or our max page size. // This number doesn't change to keep a valid offset. // @@ -73,9 +72,10 @@ func (s searcher) Code(query Query) (CodeResult, error) { // We request page #1 for 100 items and get items 0 to 99. // Then we request page #2 for 100 items, we get items 100 to 199 and only keep 100 to 149. // If we were to request page #2 for 50 items, we would instead get items 50 to 99. - query.Limit = min(toRetrieve, maxPerPage) + numItemsToRetrieve := query.Limit + query.Limit = min(numItemsToRetrieve, maxPerPage) - for toRetrieve > 0 { + for numItemsToRetrieve > 0 { query.Page = nextPage(resp) if query.Page == 0 { break @@ -89,11 +89,11 @@ func (s searcher) Code(query Query) (CodeResult, error) { // If we're going to reach the requested limit, only add that many items, // otherwise add all the results. - itemsToAdd := min(len(page.Items), toRetrieve) + numItemsToAdd := min(len(page.Items), numItemsToRetrieve) result.IncompleteResults = page.IncompleteResults result.Total = page.Total - result.Items = append(result.Items, page.Items[:itemsToAdd]...) - toRetrieve = toRetrieve - itemsToAdd + result.Items = append(result.Items, page.Items[:numItemsToAdd]...) + numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd } return result, nil @@ -105,10 +105,10 @@ func (s searcher) Commits(query Query) (CommitsResult, error) { var resp *http.Response var err error - toRetrieve := query.Limit - query.Limit = min(toRetrieve, maxPerPage) + numItemsToRetrieve := query.Limit + query.Limit = min(numItemsToRetrieve, maxPerPage) - for toRetrieve > 0 { + for numItemsToRetrieve > 0 { query.Page = nextPage(resp) if query.Page == 0 { break @@ -120,11 +120,11 @@ func (s searcher) Commits(query Query) (CommitsResult, error) { return result, err } - itemsToAdd := min(len(page.Items), toRetrieve) + numItemsToAdd := min(len(page.Items), numItemsToRetrieve) result.IncompleteResults = page.IncompleteResults result.Total = page.Total - result.Items = append(result.Items, page.Items[:itemsToAdd]...) - toRetrieve = toRetrieve - itemsToAdd + result.Items = append(result.Items, page.Items[:numItemsToAdd]...) + numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd } return result, nil } @@ -135,10 +135,10 @@ func (s searcher) Repositories(query Query) (RepositoriesResult, error) { var resp *http.Response var err error - toRetrieve := query.Limit - query.Limit = min(toRetrieve, maxPerPage) + numItemsToRetrieve := query.Limit + query.Limit = min(numItemsToRetrieve, maxPerPage) - for toRetrieve > 0 { + for numItemsToRetrieve > 0 { query.Page = nextPage(resp) if query.Page == 0 { break @@ -150,11 +150,11 @@ func (s searcher) Repositories(query Query) (RepositoriesResult, error) { return result, err } - itemsToAdd := min(len(page.Items), toRetrieve) + numItemsToAdd := min(len(page.Items), numItemsToRetrieve) result.IncompleteResults = page.IncompleteResults result.Total = page.Total - result.Items = append(result.Items, page.Items[:itemsToAdd]...) - toRetrieve = toRetrieve - itemsToAdd + result.Items = append(result.Items, page.Items[:numItemsToAdd]...) + numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd } return result, nil } @@ -165,10 +165,10 @@ func (s searcher) Issues(query Query) (IssuesResult, error) { var resp *http.Response var err error - toRetrieve := query.Limit - query.Limit = min(toRetrieve, maxPerPage) + numItemsToRetrieve := query.Limit + query.Limit = min(numItemsToRetrieve, maxPerPage) - for toRetrieve > 0 { + for numItemsToRetrieve > 0 { query.Page = nextPage(resp) if query.Page == 0 { break @@ -180,15 +180,24 @@ func (s searcher) Issues(query Query) (IssuesResult, error) { return result, err } - itemsToAdd := min(len(page.Items), toRetrieve) + numItemsToAdd := min(len(page.Items), numItemsToRetrieve) result.IncompleteResults = page.IncompleteResults result.Total = page.Total - result.Items = append(result.Items, page.Items[:itemsToAdd]...) - toRetrieve = toRetrieve - itemsToAdd + result.Items = append(result.Items, page.Items[:numItemsToAdd]...) + numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd } return result, nil } +// search makes a single-page REST search request for code, commits, issues, prs, or repos. +// +// The result argument is populated with the following information: +// +// - Total: the number of search results matching the query, which may exceed the number of items returned +// - IncompleteResults: whether the search request exceeded search time limit, potentially being incomplete +// - Items: the actual matching search results, up to 100 max items per page +// +// For more information, see https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28. func (s searcher) search(query Query, result interface{}) (*http.Response, error) { path := fmt.Sprintf("%ssearch/%s", ghinstance.RESTPrefix(s.host), query.Kind) qs := url.Values{} From 4a885899d6a724e2c82b52949b7d6a9151d2559c Mon Sep 17 00:00:00 2001 From: leudz Date: Wed, 16 Apr 2025 20:46:29 +0200 Subject: [PATCH 147/147] Add comments for Total --- pkg/search/result.go | 12 ++++++++---- pkg/search/searcher.go | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/search/result.go b/pkg/search/result.go index 0c7c43cd7..0b9d1ab16 100644 --- a/pkg/search/result.go +++ b/pkg/search/result.go @@ -93,25 +93,29 @@ var PullRequestFields = append(IssueFields, type CodeResult struct { IncompleteResults bool `json:"incomplete_results"` Items []Code `json:"items"` - Total int `json:"total_count"` + // Number of code search results matching the query on the server. Ignoring limit. + Total int `json:"total_count"` } type CommitsResult struct { IncompleteResults bool `json:"incomplete_results"` Items []Commit `json:"items"` - Total int `json:"total_count"` + // Number of commits matching the query on the server. Ignoring limit. + Total int `json:"total_count"` } type RepositoriesResult struct { IncompleteResults bool `json:"incomplete_results"` Items []Repository `json:"items"` - Total int `json:"total_count"` + // Number of repositories matching the query on the server. Ignoring limit. + Total int `json:"total_count"` } type IssuesResult struct { IncompleteResults bool `json:"incomplete_results"` Items []Issue `json:"items"` - Total int `json:"total_count"` + // Number of isssues matching the query on the server. Ignoring limit. + Total int `json:"total_count"` } type Code struct { diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index d1f86fdcd..7cbd35562 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -91,6 +91,8 @@ func (s searcher) Code(query Query) (CodeResult, error) { // otherwise add all the results. numItemsToAdd := min(len(page.Items), numItemsToRetrieve) result.IncompleteResults = page.IncompleteResults + // The API returns how many items match the query in every response. + // With the example above, this would be 500. result.Total = page.Total result.Items = append(result.Items, page.Items[:numItemsToAdd]...) numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd