From 2f81a33e95e3a0e1a44b017ecf6446cfcc5701b6 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Fri, 24 Jan 2025 09:28:55 -0700 Subject: [PATCH 01/56] add new signing options Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verify/options.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/attestation/verify/options.go b/pkg/cmd/attestation/verify/options.go index 4296cb8ec..3ac642904 100644 --- a/pkg/cmd/attestation/verify/options.go +++ b/pkg/cmd/attestation/verify/options.go @@ -31,8 +31,12 @@ type Options struct { Repo string SAN string SANRegex string + SignerDigest string + SignerRef string SignerRepo string SignerWorkflow string + SourceRef string + SourceDigest string APIClient api.Client Logger *io.Handler OCIClient oci.Client From 11dc8d48f54e068ee8775f16faaaaf34c4fb515c Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Fri, 24 Jan 2025 13:19:47 -0700 Subject: [PATCH 02/56] reorder fields Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verify/options.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/attestation/verify/options.go b/pkg/cmd/attestation/verify/options.go index 3ac642904..b53890431 100644 --- a/pkg/cmd/attestation/verify/options.go +++ b/pkg/cmd/attestation/verify/options.go @@ -35,8 +35,8 @@ type Options struct { SignerRef string SignerRepo string SignerWorkflow string - SourceRef string SourceDigest string + SourceRef string APIClient api.Client Logger *io.Handler OCIClient oci.Client From 728aa3d83fbc30d7978af82f31652cf9f81ae300 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Fri, 24 Jan 2025 13:20:01 -0700 Subject: [PATCH 03/56] set new options in enforcement criteria Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verify/policy.go | 38 ++++++++++++++++++++++- pkg/cmd/attestation/verify/policy_test.go | 14 +++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/attestation/verify/policy.go b/pkg/cmd/attestation/verify/policy.go index 1e1dcd4d8..bb6ddb278 100644 --- a/pkg/cmd/attestation/verify/policy.go +++ b/pkg/cmd/attestation/verify/policy.go @@ -14,6 +14,7 @@ import ( ) const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` +const workflowURIRegex = `^https:\/\/[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*\/.github\/workflows\/[a-zA-Z0-9-]+.(yml|yaml)$` func expandToGitHubURL(tenant, ownerOrRepo string) string { if tenant == "" { @@ -66,7 +67,7 @@ func newEnforcementCriteria(opts *Options) (verification.EnforcementCriteria, er // then we default to the repo option c.SANRegex = expandToGitHubURLRegex(opts.Tenant, opts.Repo) } else { - // if opts.Repo was not provided, we fallback to the opts.Owner value + // if opts.Repo was not provided, we fall back to the opts.Owner value c.SANRegex = expandToGitHubURLRegex(opts.Tenant, owner) } @@ -98,9 +99,44 @@ func newEnforcementCriteria(opts *Options) (verification.EnforcementCriteria, er c.Certificate.Issuer = opts.OIDCIssuer } + if opts.SignerDigest != "" { + c.Certificate.BuildSignerDigest = opts.SignerDigest + } + + if opts.SignerRef != "" { + // need to build the full URI value + uri, err := getFullWorkflowURI(c.SANRegex) + if err != nil { + return verification.EnforcementCriteria{}, err + } + c.Certificate.BuildSignerURI = uri + } + + if opts.SourceDigest != "" { + c.Certificate.SourceRepositoryDigest = opts.SourceDigest + } + + if opts.SourceRef != "" { + c.Certificate.SourceRepositoryRef = opts.SourceRef + } + return c, nil } +func getFullWorkflowURI(s string) (string, error) { + trimmed, _ := strings.CutPrefix(s, "^") + match, err := regexp.MatchString(workflowURIRegex, trimmed) + if err != nil { + return "", err + } + + if !match { + return "", nil + } + + return trimmed, nil +} + func buildCertificateIdentityOption(c verification.EnforcementCriteria) (verify.PolicyOption, error) { sanMatcher, err := verify.NewSANMatcher(c.SAN, c.SANRegex) if err != nil { diff --git a/pkg/cmd/attestation/verify/policy_test.go b/pkg/cmd/attestation/verify/policy_test.go index d033ba4fa..ecaf74ec3 100644 --- a/pkg/cmd/attestation/verify/policy_test.go +++ b/pkg/cmd/attestation/verify/policy_test.go @@ -1,6 +1,7 @@ package verify import ( + "fmt" "testing" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" @@ -275,3 +276,16 @@ func TestValidateSignerWorkflow(t *testing.T) { } } } + +func TestGetFullWorkflowURI(t *testing.T) { + expectedURI := "https://github.com/foo/bar/.github/workflows/mybuildjob.yaml" + // exact matching + uri, err := getFullWorkflowURI(expectedURI) + require.NoError(t, err) + require.Equal(t, expectedURI, uri) + + // matching after stripping regex prefix characters + uri, err = getFullWorkflowURI(fmt.Sprintf("^%s", expectedURI)) + require.NoError(t, err) + require.Equal(t, expectedURI, uri) +} From 82ee9a2c75a0b1dcd942a2c18da41f4d45b81698 Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Wed, 29 Jan 2025 14:53:09 +0500 Subject: [PATCH 04/56] [gh cache delete --all] Add `--succeed-on-no-caches` flag to return exit code 0 --- pkg/cmd/cache/delete/delete.go | 33 +++++++++++++++++++++++------ pkg/cmd/cache/delete/delete_test.go | 16 ++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go index 65a9d696a..81138ddd0 100644 --- a/pkg/cmd/cache/delete/delete.go +++ b/pkg/cmd/cache/delete/delete.go @@ -22,8 +22,9 @@ type DeleteOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams - DeleteAll bool - Identifier string + DeleteAll bool + SucceedOnNoCaches bool + Identifier string } func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { @@ -33,7 +34,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co } cmd := &cobra.Command{ - Use: "delete [| | --all]", + Use: "delete [ | | --all]", Short: "Delete GitHub Actions caches", Long: heredoc.Docf(` Delete GitHub Actions caches. @@ -50,8 +51,11 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co # Delete a cache by id in a specific repo $ gh cache delete 1234 --repo cli/cli - # Delete all caches + # Delete all caches (exit code 1 on no caches) $ gh cache delete --all + + # Delete all caches (exit code 0 on no caches) + $ gh cache delete --all --succeed-on-no-caches `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -65,6 +69,10 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return err } + if opts.SucceedOnNoCaches && (!opts.DeleteAll || len(args) == 1) { + return cmdutil.FlagErrorf("--succeed-on-no-caches must be used in conjunction with --all") + } + if !opts.DeleteAll && len(args) == 0 { return cmdutil.FlagErrorf("must provide either cache id, cache key, or use --all") } @@ -82,6 +90,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co } cmd.Flags().BoolVarP(&opts.DeleteAll, "all", "a", false, "Delete all caches") + cmd.Flags().BoolVarP(&opts.SucceedOnNoCaches, "succeed-on-no-caches", "s", false, "Return exit code 0 if no caches found. Must be used in conjunction with `--all`") return cmd } @@ -100,12 +109,24 @@ func deleteRun(opts *DeleteOptions) error { var toDelete []string if opts.DeleteAll { + opts.IO.StartProgressIndicator() caches, err := shared.GetCaches(client, repo, shared.GetCachesOptions{Limit: -1}) + opts.IO.StopProgressIndicator() if err != nil { - return err + if opts.SucceedOnNoCaches { + fmt.Println(err) + return nil + } else { + return err + } } if len(caches.ActionsCaches) == 0 { - return fmt.Errorf("%s No caches to delete", opts.IO.ColorScheme().FailureIcon()) + if opts.SucceedOnNoCaches { + fmt.Printf("%s No caches to delete\n", opts.IO.ColorScheme().SuccessIcon()) + return nil + } else { + return fmt.Errorf("%s No caches to delete", opts.IO.ColorScheme().FailureIcon()) + } } for _, cache := range caches.ActionsCaches { toDelete = append(toDelete, strconv.Itoa(cache.Id)) diff --git a/pkg/cmd/cache/delete/delete_test.go b/pkg/cmd/cache/delete/delete_test.go index 43fc7d213..e81fdbcec 100644 --- a/pkg/cmd/cache/delete/delete_test.go +++ b/pkg/cmd/cache/delete/delete_test.go @@ -43,6 +43,21 @@ func TestNewCmdDelete(t *testing.T) { cli: "--all", wants: DeleteOptions{DeleteAll: true}, }, + { + name: "delete all and succeed-on-no-caches flags", + cli: "--all --succeed-on-no-caches", + wants: DeleteOptions{DeleteAll: true, SucceedOnNoCaches: true}, + }, + { + name: "succeed-on-no-caches flag", + cli: "--succeed-on-no-caches", + wantsErr: "--succeed-on-no-caches must be used in conjunction with --all", + }, + { + name: "succeed-on-no-caches flag and id argument", + cli: "--succeed-on-no-caches 123", + wantsErr: "--succeed-on-no-caches must be used in conjunction with --all", + }, { name: "id argument and delete all flag", cli: "1 --all", @@ -72,6 +87,7 @@ func TestNewCmdDelete(t *testing.T) { } assert.NoError(t, err) assert.Equal(t, tt.wants.DeleteAll, gotOpts.DeleteAll) + assert.Equal(t, tt.wants.SucceedOnNoCaches, gotOpts.SucceedOnNoCaches) assert.Equal(t, tt.wants.Identifier, gotOpts.Identifier) }) } From 015c73005a89807111dfb0212b539818e59225cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jan 2025 14:25:54 +0000 Subject: [PATCH 05/56] Bump github.com/spf13/pflag from 1.0.5 to 1.0.6 Bumps [github.com/spf13/pflag](https://github.com/spf13/pflag) from 1.0.5 to 1.0.6. - [Release notes](https://github.com/spf13/pflag/releases) - [Commits](https://github.com/spf13/pflag/compare/v1.0.5...v1.0.6) --- updated-dependencies: - dependency-name: github.com/spf13/pflag dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 38837b883..3e843973a 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ require ( 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/spf13/pflag v1.0.5 + 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.32.0 diff --git a/go.sum b/go.sum index 883ac72ce..725a9d755 100644 --- a/go.sum +++ b/go.sum @@ -433,8 +433,9 @@ 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 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= From 313faf9cd08d8aa47cb2b8118410852393b7597e Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Thu, 30 Jan 2025 07:43:13 -0700 Subject: [PATCH 06/56] add signer and source ref, commit options Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verify/policy.go | 44 +++++++---------- pkg/cmd/attestation/verify/policy_test.go | 57 +++++++++++++++++++++++ pkg/cmd/attestation/verify/verify.go | 10 ++-- 3 files changed, 81 insertions(+), 30 deletions(-) diff --git a/pkg/cmd/attestation/verify/policy.go b/pkg/cmd/attestation/verify/policy.go index bb6ddb278..759867509 100644 --- a/pkg/cmd/attestation/verify/policy.go +++ b/pkg/cmd/attestation/verify/policy.go @@ -13,8 +13,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/verification" ) -const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` -const workflowURIRegex = `^https:\/\/[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*\/.github\/workflows\/[a-zA-Z0-9-]+.(yml|yaml)$` +const workflowURIRegex = `^https:\/\/[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*\/.github\/workflows\/.*\/[a-zA-Z0-9-]+.(yml|yaml)$` func expandToGitHubURL(tenant, ownerOrRepo string) string { if tenant == "" { @@ -57,11 +56,17 @@ func newEnforcementCriteria(opts *Options) (verification.EnforcementCriteria, er signedRepoRegex := expandToGitHubURLRegex(opts.Tenant, opts.SignerRepo) c.SANRegex = signedRepoRegex } else if opts.SignerWorkflow != "" { - validatedWorkflowRegex, err := validateSignerWorkflow(opts.Hostname, opts.SignerWorkflow) + validatedWorkflow, err := validateSignerWorkflow(opts.Hostname, opts.SignerWorkflow) if err != nil { return verification.EnforcementCriteria{}, err } - c.SANRegex = validatedWorkflowRegex + + workflowRegex := fmt.Sprintf("^%s", validatedWorkflow) + c.SANRegex = workflowRegex + + if opts.SignerRef != "" { + c.Certificate.BuildSignerURI = fmt.Sprintf("%s@%s", validatedWorkflow, opts.SignerRef) + } } else if opts.Repo != "" { // if the user has not provided the SAN, SANRegex, SignerRepo, or SignerWorkflow options // then we default to the repo option @@ -99,26 +104,11 @@ func newEnforcementCriteria(opts *Options) (verification.EnforcementCriteria, er c.Certificate.Issuer = opts.OIDCIssuer } - if opts.SignerDigest != "" { - c.Certificate.BuildSignerDigest = opts.SignerDigest - } - - if opts.SignerRef != "" { - // need to build the full URI value - uri, err := getFullWorkflowURI(c.SANRegex) - if err != nil { - return verification.EnforcementCriteria{}, err - } - c.Certificate.BuildSignerURI = uri - } - - if opts.SourceDigest != "" { - c.Certificate.SourceRepositoryDigest = opts.SourceDigest - } - - if opts.SourceRef != "" { - c.Certificate.SourceRepositoryRef = opts.SourceRef - } + // set the SourceRepositoryDigest, SourceRepositoryRef, and BuildSignerDigest + // extensions if the options are provided + c.Certificate.BuildSignerDigest = opts.SignerDigest + c.Certificate.SourceRepositoryDigest = opts.SourceDigest + c.Certificate.SourceRepositoryRef = opts.SourceRef return c, nil } @@ -179,13 +169,13 @@ func buildSigstoreVerifyPolicy(c verification.EnforcementCriteria, a artifact.Di func validateSignerWorkflow(hostname, signerWorkflow string) (string, error) { // we expect a provided workflow argument be in the format [HOST/]///path/to/workflow.yml // if the provided workflow does not contain a host, set the host - match, err := regexp.MatchString(hostRegex, signerWorkflow) + match, err := regexp.MatchString(workflowURIRegex, signerWorkflow) if err != nil { return "", err } if match { - return fmt.Sprintf("^https://%s", signerWorkflow), nil + return fmt.Sprintf("https://%s", signerWorkflow), nil } // if the provided workflow did not match the expect format @@ -194,5 +184,5 @@ func validateSignerWorkflow(hostname, signerWorkflow string) (string, error) { return "", errors.New("unknown host") } - return fmt.Sprintf("^https://%s/%s", hostname, signerWorkflow), nil + return fmt.Sprintf("https://%s/%s", hostname, signerWorkflow), nil } diff --git a/pkg/cmd/attestation/verify/policy_test.go b/pkg/cmd/attestation/verify/policy_test.go index ecaf74ec3..a05e3377d 100644 --- a/pkg/cmd/attestation/verify/policy_test.go +++ b/pkg/cmd/attestation/verify/policy_test.go @@ -217,6 +217,63 @@ func TestNewEnforcementCriteria(t *testing.T) { require.NoError(t, err) require.Equal(t, "https://foo.com", c.Certificate.Issuer) }) + + t.Run("sets Certificate.BuildSignerURI using SignerWorkflow and SignerRef", func(t *testing.T) { + opts := &Options{ + ArtifactPath: artifactPath, + Owner: "wrong", + Repo: "wrong/value", + SignerWorkflow: "foo/bar/.github/workflows/attest.yml", + SignerRef: "refs/heads/main", + Hostname: "github.com", + } + + c, err := newEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "https://github.com/foo/bar/.github/workflows/attest.yml@refs/heads/main", c.Certificate.BuildSignerURI) + }) + + t.Run("sets Certificate.BuildSignerDigest using opts.SignerDigest", func(t *testing.T) { + opts := &Options{ + ArtifactPath: artifactPath, + Owner: "wrong", + Repo: "wrong/value", + SignerDigest: "foo", + Hostname: "github.com", + } + + c, err := newEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "foo", c.Certificate.BuildSignerDigest) + }) + + t.Run("sets Certificate.SourceRepositoryDigest using opts.SourceDigest", func(t *testing.T) { + opts := &Options{ + ArtifactPath: artifactPath, + Owner: "wrong", + Repo: "wrong/value", + SourceDigest: "foo", + Hostname: "github.com", + } + + c, err := newEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "foo", c.Certificate.SourceRepositoryDigest) + }) + + t.Run("sets Certificate.SourceRepositoryRef using opts.SourceRef", func(t *testing.T) { + opts := &Options{ + ArtifactPath: artifactPath, + Owner: "wrong", + Repo: "wrong/value", + SourceRef: "refs/heads/main", + Hostname: "github.com", + } + + c, err := newEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "refs/heads/main", c.Certificate.SourceRepositoryRef) + }) } func TestValidateSignerWorkflow(t *testing.T) { diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index ea7502f00..c1ec63bbb 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -187,14 +187,18 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command verifyCmd.Flags().IntVarP(&opts.Limit, "limit", "L", api.DefaultLimit, "Maximum number of attestations to fetch") cmdutil.AddFormatFlags(verifyCmd, &opts.exporter) // policy enforcement flags - verifyCmd.Flags().BoolVarP(&opts.DenySelfHostedRunner, "deny-self-hosted-runners", "", false, "Fail verification for attestations generated on self-hosted runners") verifyCmd.Flags().StringVarP(&opts.SAN, "cert-identity", "", "", "Enforce that the certificate's subject alternative name matches the provided value exactly") verifyCmd.Flags().StringVarP(&opts.SANRegex, "cert-identity-regex", "i", "", "Enforce that the certificate's subject alternative name matches the provided regex") - verifyCmd.Flags().StringVarP(&opts.SignerRepo, "signer-repo", "", "", "Repository of reusable workflow that signed attestation in the format /") - verifyCmd.Flags().StringVarP(&opts.SignerWorkflow, "signer-workflow", "", "", "Workflow that signed attestation in the format [host/]////") verifyCmd.MarkFlagsMutuallyExclusive("cert-identity", "cert-identity-regex", "signer-repo", "signer-workflow") verifyCmd.Flags().StringVarP(&opts.OIDCIssuer, "cert-oidc-issuer", "", verification.GitHubOIDCIssuer, "Issuer of the OIDC token") + verifyCmd.Flags().BoolVarP(&opts.DenySelfHostedRunner, "deny-self-hosted-runners", "", false, "Fail verification for attestations generated on self-hosted runners") verifyCmd.Flags().StringVarP(&opts.Hostname, "hostname", "", "", "Configure host to use") + verifyCmd.Flags().StringVarP(&opts.SignerDigest, "signer-digest", "", "", "Digest associated with the signer workflow") + verifyCmd.Flags().StringVarP(&opts.SignerRepo, "signer-repo", "", "", "Repository of reusable workflow that signed attestation in the format /") + verifyCmd.Flags().StringVarP(&opts.SignerWorkflow, "signer-workflow", "", "", "Workflow that signed attestation in the format [host/]////") + verifyCmd.Flags().StringVarP(&opts.SignerRef, "signer-ref", "", "", "Ref associated with the signer workflow. signer-workflow must be provided to use this") + verifyCmd.Flags().StringVarP(&opts.SourceRef, "source-ref", "", "", "Ref associated with the source workflow") + verifyCmd.Flags().StringVarP(&opts.SourceDigest, "source-digest", "", "", "Digest associated with the source workflow") return verifyCmd } From c6b5928ddc6b6e92524d4890192d417daf07d5d9 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Thu, 30 Jan 2025 07:58:42 -0700 Subject: [PATCH 07/56] fix issues causing tests to fail Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verification/policy.go | 15 ++++++++++++++- pkg/cmd/attestation/verify/policy.go | 18 ++---------------- pkg/cmd/attestation/verify/policy_test.go | 22 ++++------------------ pkg/cmd/attestation/verify/verify.go | 2 +- 4 files changed, 21 insertions(+), 36 deletions(-) diff --git a/pkg/cmd/attestation/verification/policy.go b/pkg/cmd/attestation/verification/policy.go index f2bd126d0..487542a58 100644 --- a/pkg/cmd/attestation/verification/policy.go +++ b/pkg/cmd/attestation/verification/policy.go @@ -52,7 +52,7 @@ func (c EnforcementCriteria) Valid() error { } func (c EnforcementCriteria) BuildPolicyInformation() string { - policyAttr := make([][]string, 0, 6) + policyAttr := [][]string{} policyAttr = appendStr(policyAttr, "- Predicate type must match", c.PredicateType) @@ -62,6 +62,19 @@ func (c EnforcementCriteria) BuildPolicyInformation() string { policyAttr = appendStr(policyAttr, "- Source Repository URI must match", c.Certificate.SourceRepositoryURI) } + if c.Certificate.BuildSignerDigest != "" { + policyAttr = appendStr(policyAttr, "- Build signer digest must match", c.Certificate.BuildSignerDigest) + } + if c.Certificate.BuildSignerURI != "" { + policyAttr = appendStr(policyAttr, "- Build signer URI must match", c.Certificate.BuildSignerURI) + } + if c.Certificate.SourceRepositoryDigest != "" { + policyAttr = appendStr(policyAttr, "- Source repo digest digest must match", c.Certificate.SourceRepositoryDigest) + } + if c.Certificate.SourceRepositoryRef != "" { + policyAttr = appendStr(policyAttr, "- Source repo ref must match", c.Certificate.SourceRepositoryRef) + } + if c.SAN != "" { policyAttr = appendStr(policyAttr, "- Subject Alternative Name must match", c.SAN) } else if c.SANRegex != "" { diff --git a/pkg/cmd/attestation/verify/policy.go b/pkg/cmd/attestation/verify/policy.go index 759867509..c42d873d6 100644 --- a/pkg/cmd/attestation/verify/policy.go +++ b/pkg/cmd/attestation/verify/policy.go @@ -13,7 +13,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/verification" ) -const workflowURIRegex = `^https:\/\/[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*\/.github\/workflows\/.*\/[a-zA-Z0-9-]+.(yml|yaml)$` +const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` func expandToGitHubURL(tenant, ownerOrRepo string) string { if tenant == "" { @@ -113,20 +113,6 @@ func newEnforcementCriteria(opts *Options) (verification.EnforcementCriteria, er return c, nil } -func getFullWorkflowURI(s string) (string, error) { - trimmed, _ := strings.CutPrefix(s, "^") - match, err := regexp.MatchString(workflowURIRegex, trimmed) - if err != nil { - return "", err - } - - if !match { - return "", nil - } - - return trimmed, nil -} - func buildCertificateIdentityOption(c verification.EnforcementCriteria) (verify.PolicyOption, error) { sanMatcher, err := verify.NewSANMatcher(c.SAN, c.SANRegex) if err != nil { @@ -169,7 +155,7 @@ func buildSigstoreVerifyPolicy(c verification.EnforcementCriteria, a artifact.Di func validateSignerWorkflow(hostname, signerWorkflow string) (string, error) { // we expect a provided workflow argument be in the format [HOST/]///path/to/workflow.yml // if the provided workflow does not contain a host, set the host - match, err := regexp.MatchString(workflowURIRegex, signerWorkflow) + match, err := regexp.MatchString(hostRegex, signerWorkflow) if err != nil { return "", err } diff --git a/pkg/cmd/attestation/verify/policy_test.go b/pkg/cmd/attestation/verify/policy_test.go index a05e3377d..83ab893de 100644 --- a/pkg/cmd/attestation/verify/policy_test.go +++ b/pkg/cmd/attestation/verify/policy_test.go @@ -1,7 +1,6 @@ package verify import ( - "fmt" "testing" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" @@ -296,25 +295,25 @@ func TestValidateSignerWorkflow(t *testing.T) { { name: "workflow with default host", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: "^https://github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", + expectedWorkflowRegex: "https://github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", host: "github.com", }, { name: "workflow with workflow URL included", providedSignerWorkflow: "github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: "^https://github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", + expectedWorkflowRegex: "https://github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", host: "github.com", }, { name: "workflow with GH_HOST set", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: "^https://myhost.github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", + expectedWorkflowRegex: "https://myhost.github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", host: "myhost.github.com", }, { name: "workflow with authenticated host", providedSignerWorkflow: "github/artifact-attestations-workflows/.github/workflows/attest.yml", - expectedWorkflowRegex: "^https://authedhost.github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", + expectedWorkflowRegex: "https://authedhost.github.com/github/artifact-attestations-workflows/.github/workflows/attest.yml", host: "authedhost.github.com", }, } @@ -333,16 +332,3 @@ func TestValidateSignerWorkflow(t *testing.T) { } } } - -func TestGetFullWorkflowURI(t *testing.T) { - expectedURI := "https://github.com/foo/bar/.github/workflows/mybuildjob.yaml" - // exact matching - uri, err := getFullWorkflowURI(expectedURI) - require.NoError(t, err) - require.Equal(t, expectedURI, uri) - - // matching after stripping regex prefix characters - uri, err = getFullWorkflowURI(fmt.Sprintf("^%s", expectedURI)) - require.NoError(t, err) - require.Equal(t, expectedURI, uri) -} diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index c1ec63bbb..97fe85845 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -194,9 +194,9 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command verifyCmd.Flags().BoolVarP(&opts.DenySelfHostedRunner, "deny-self-hosted-runners", "", false, "Fail verification for attestations generated on self-hosted runners") verifyCmd.Flags().StringVarP(&opts.Hostname, "hostname", "", "", "Configure host to use") verifyCmd.Flags().StringVarP(&opts.SignerDigest, "signer-digest", "", "", "Digest associated with the signer workflow") + verifyCmd.Flags().StringVarP(&opts.SignerRef, "signer-ref", "", "", "Ref associated with the signer workflow. signer-workflow must be provided to use this") verifyCmd.Flags().StringVarP(&opts.SignerRepo, "signer-repo", "", "", "Repository of reusable workflow that signed attestation in the format /") verifyCmd.Flags().StringVarP(&opts.SignerWorkflow, "signer-workflow", "", "", "Workflow that signed attestation in the format [host/]////") - verifyCmd.Flags().StringVarP(&opts.SignerRef, "signer-ref", "", "", "Ref associated with the signer workflow. signer-workflow must be provided to use this") verifyCmd.Flags().StringVarP(&opts.SourceRef, "source-ref", "", "", "Ref associated with the source workflow") verifyCmd.Flags().StringVarP(&opts.SourceDigest, "source-digest", "", "", "Digest associated with the source workflow") From e10010c4cfc9de2c3f36c87a4cbf8726cd01c766 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Thu, 30 Jan 2025 08:03:36 -0700 Subject: [PATCH 08/56] fix option ordering Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verify/verify.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index 97fe85845..f95867e8a 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -187,16 +187,16 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command verifyCmd.Flags().IntVarP(&opts.Limit, "limit", "L", api.DefaultLimit, "Maximum number of attestations to fetch") cmdutil.AddFormatFlags(verifyCmd, &opts.exporter) // policy enforcement flags + verifyCmd.Flags().BoolVarP(&opts.DenySelfHostedRunner, "deny-self-hosted-runners", "", false, "Fail verification for attestations generated on self-hosted runners") verifyCmd.Flags().StringVarP(&opts.SAN, "cert-identity", "", "", "Enforce that the certificate's subject alternative name matches the provided value exactly") verifyCmd.Flags().StringVarP(&opts.SANRegex, "cert-identity-regex", "i", "", "Enforce that the certificate's subject alternative name matches the provided regex") + verifyCmd.Flags().StringVarP(&opts.SignerRepo, "signer-repo", "", "", "Repository of reusable workflow that signed attestation in the format /") + verifyCmd.Flags().StringVarP(&opts.SignerWorkflow, "signer-workflow", "", "", "Workflow that signed attestation in the format [host/]////") verifyCmd.MarkFlagsMutuallyExclusive("cert-identity", "cert-identity-regex", "signer-repo", "signer-workflow") verifyCmd.Flags().StringVarP(&opts.OIDCIssuer, "cert-oidc-issuer", "", verification.GitHubOIDCIssuer, "Issuer of the OIDC token") - verifyCmd.Flags().BoolVarP(&opts.DenySelfHostedRunner, "deny-self-hosted-runners", "", false, "Fail verification for attestations generated on self-hosted runners") verifyCmd.Flags().StringVarP(&opts.Hostname, "hostname", "", "", "Configure host to use") verifyCmd.Flags().StringVarP(&opts.SignerDigest, "signer-digest", "", "", "Digest associated with the signer workflow") verifyCmd.Flags().StringVarP(&opts.SignerRef, "signer-ref", "", "", "Ref associated with the signer workflow. signer-workflow must be provided to use this") - verifyCmd.Flags().StringVarP(&opts.SignerRepo, "signer-repo", "", "", "Repository of reusable workflow that signed attestation in the format /") - verifyCmd.Flags().StringVarP(&opts.SignerWorkflow, "signer-workflow", "", "", "Workflow that signed attestation in the format [host/]////") verifyCmd.Flags().StringVarP(&opts.SourceRef, "source-ref", "", "", "Ref associated with the source workflow") verifyCmd.Flags().StringVarP(&opts.SourceDigest, "source-digest", "", "", "Digest associated with the source workflow") From 1c326c74f01c0a7b817b79739b3a0479a6a3bed4 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Thu, 30 Jan 2025 08:14:13 -0700 Subject: [PATCH 09/56] add checks to cert extensions func Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verification/extensions.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pkg/cmd/attestation/verification/extensions.go b/pkg/cmd/attestation/verification/extensions.go index 2958408d0..6770dca63 100644 --- a/pkg/cmd/attestation/verification/extensions.go +++ b/pkg/cmd/attestation/verification/extensions.go @@ -59,5 +59,18 @@ func verifyCertExtensions(given, expected certificate.Summary) error { return fmt.Errorf("expected Issuer to be %s, got %s", expected.Issuer, given.Issuer) } + if expected.BuildSignerDigest != "" && !strings.EqualFold(expected.BuildSignerDigest, given.BuildSignerDigest) { + return fmt.Errorf("expected BuildSignerDigest to be %s, got %s", expected.BuildSignerDigest, given.BuildSignerDigest) + } + if expected.BuildSignerURI != "" && !strings.EqualFold(expected.BuildSignerURI, given.BuildSignerURI) { + return fmt.Errorf("expected BuildSignerURI to be %s, got %s", expected.BuildSignerURI, given.BuildSignerURI) + } + if expected.SourceRepositoryDigest != "" && !strings.EqualFold(expected.SourceRepositoryDigest, given.SourceRepositoryDigest) { + return fmt.Errorf("expected SourceRepositoryDigest to be %s, got %s", expected.SourceRepositoryDigest, given.SourceRepositoryDigest) + } + if expected.SourceRepositoryRef != "" && !strings.EqualFold(expected.SourceRepositoryRef, given.SourceRepositoryRef) { + return fmt.Errorf("expected SourceRepositoryRef to be %s, got %s", expected.SourceRepositoryRef, given.SourceRepositoryRef) + } + return nil } From d6be4981be31da2a2365df0ffbb58163c66ce130 Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Fri, 31 Jan 2025 12:28:00 +0500 Subject: [PATCH 10/56] Address PR review comments --- pkg/cmd/cache/delete/delete.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go index 81138ddd0..e244cc0a1 100644 --- a/pkg/cmd/cache/delete/delete.go +++ b/pkg/cmd/cache/delete/delete.go @@ -69,7 +69,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return err } - if opts.SucceedOnNoCaches && (!opts.DeleteAll || len(args) == 1) { + if !opts.DeleteAll && opts.SucceedOnNoCaches { return cmdutil.FlagErrorf("--succeed-on-no-caches must be used in conjunction with --all") } @@ -113,16 +113,11 @@ func deleteRun(opts *DeleteOptions) error { caches, err := shared.GetCaches(client, repo, shared.GetCachesOptions{Limit: -1}) opts.IO.StopProgressIndicator() if err != nil { - if opts.SucceedOnNoCaches { - fmt.Println(err) - return nil - } else { - return err - } + return err } if len(caches.ActionsCaches) == 0 { if opts.SucceedOnNoCaches { - fmt.Printf("%s No caches to delete\n", opts.IO.ColorScheme().SuccessIcon()) + fmt.Fprintf(opts.IO.Out, "%s No caches to delete\n", opts.IO.ColorScheme().SuccessIcon()) return nil } else { return fmt.Errorf("%s No caches to delete", opts.IO.ColorScheme().FailureIcon()) From 9e13890c77ac65d1b06b0032b4444e0caaf70583 Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Thu, 6 Feb 2025 11:56:08 +0500 Subject: [PATCH 11/56] Remove short (abbreviated) flag support --- pkg/cmd/cache/delete/delete.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go index e244cc0a1..c05674170 100644 --- a/pkg/cmd/cache/delete/delete.go +++ b/pkg/cmd/cache/delete/delete.go @@ -90,7 +90,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co } cmd.Flags().BoolVarP(&opts.DeleteAll, "all", "a", false, "Delete all caches") - cmd.Flags().BoolVarP(&opts.SucceedOnNoCaches, "succeed-on-no-caches", "s", false, "Return exit code 0 if no caches found. Must be used in conjunction with `--all`") + cmd.Flags().BoolVarP(&opts.SucceedOnNoCaches, "succeed-on-no-caches", "", false, "Return exit code 0 if no caches found. Must be used in conjunction with `--all`") return cmd } From 2f0f387e112137c0239d9605ece202a40f38504e Mon Sep 17 00:00:00 2001 From: Tyler McGoffin Date: Thu, 6 Feb 2025 09:47:01 -0800 Subject: [PATCH 12/56] Add test cases for succeed on no cache and api errors for --all (#1) --- pkg/cmd/cache/delete/delete_test.go | 61 +++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/pkg/cmd/cache/delete/delete_test.go b/pkg/cmd/cache/delete/delete_test.go index e81fdbcec..a9ec88dea 100644 --- a/pkg/cmd/cache/delete/delete_test.go +++ b/pkg/cmd/cache/delete/delete_test.go @@ -176,6 +176,19 @@ func TestDeleteRun(t *testing.T) { tty: true, wantStdout: "✓ Deleted 2 caches from OWNER/REPO\n", }, + { + name: "attempts to delete all caches but api errors", + opts: DeleteOptions{DeleteAll: true}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), + httpmock.StatusStringResponse(500, ""), + ) + }, + tty: true, + wantErr: true, + wantErrMsg: "HTTP 500 (https://api.github.com/repos/OWNER/REPO/actions/caches?per_page=100)", + }, { name: "displays delete error", opts: DeleteOptions{Identifier: "123"}, @@ -202,6 +215,54 @@ func TestDeleteRun(t *testing.T) { tty: true, wantStdout: "✓ Deleted 1 cache from OWNER/REPO\n", }, + { + name: "no caches to delete when deleting all", + opts: DeleteOptions{Identifier: "123", DeleteAll: true}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), + httpmock.JSONResponse(shared.CachePayload{ + ActionsCaches: []shared.Cache{}, + TotalCount: 0, + }), + ) + }, + tty: false, + wantErr: true, + wantErrMsg: "X No caches to delete", + }, + { + name: "no caches to delete when deleting all but succeed on no cache tty", + opts: DeleteOptions{Identifier: "123", DeleteAll: true, SucceedOnNoCaches: true}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), + httpmock.JSONResponse(shared.CachePayload{ + ActionsCaches: []shared.Cache{}, + TotalCount: 0, + }), + ) + }, + tty: true, + wantErr: false, + wantStdout: "✓ No caches to delete\n", + }, + { + name: "no caches to delete when deleting all but succeed on no cache non-tty", + opts: DeleteOptions{Identifier: "123", DeleteAll: true, SucceedOnNoCaches: true}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), + httpmock.JSONResponse(shared.CachePayload{ + ActionsCaches: []shared.Cache{}, + TotalCount: 0, + }), + ) + }, + tty: false, + wantErr: false, + wantStdout: "", + }, } for _, tt := range tests { From 09ec38e8d776790089eac7865c6cfcf0a0e79834 Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Thu, 6 Feb 2025 22:50:26 +0500 Subject: [PATCH 13/56] Update tests --- pkg/cmd/cache/delete/delete_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/cache/delete/delete_test.go b/pkg/cmd/cache/delete/delete_test.go index a9ec88dea..57ecb9381 100644 --- a/pkg/cmd/cache/delete/delete_test.go +++ b/pkg/cmd/cache/delete/delete_test.go @@ -217,7 +217,7 @@ func TestDeleteRun(t *testing.T) { }, { name: "no caches to delete when deleting all", - opts: DeleteOptions{Identifier: "123", DeleteAll: true}, + opts: DeleteOptions{DeleteAll: true}, stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), @@ -233,7 +233,7 @@ func TestDeleteRun(t *testing.T) { }, { name: "no caches to delete when deleting all but succeed on no cache tty", - opts: DeleteOptions{Identifier: "123", DeleteAll: true, SucceedOnNoCaches: true}, + opts: DeleteOptions{DeleteAll: true, SucceedOnNoCaches: true}, stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), @@ -249,7 +249,7 @@ func TestDeleteRun(t *testing.T) { }, { name: "no caches to delete when deleting all but succeed on no cache non-tty", - opts: DeleteOptions{Identifier: "123", DeleteAll: true, SucceedOnNoCaches: true}, + opts: DeleteOptions{DeleteAll: true, SucceedOnNoCaches: true}, stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), @@ -261,7 +261,7 @@ func TestDeleteRun(t *testing.T) { }, tty: false, wantErr: false, - wantStdout: "", + wantStdout: "✓ No caches to delete\n", }, } From 4107944f39724c1bccfa9c2f16d7f5c3ee585713 Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Fri, 7 Feb 2025 13:12:04 +0500 Subject: [PATCH 14/56] [gh api] Escape package name (URL encoding) for packages endpoint --- pkg/cmd/api/api.go | 17 +++++++++++ pkg/cmd/api/api_test.go | 66 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index a6eb69718..99b4fb659 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -264,6 +265,8 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command return err } + opts.RequestPath = escapePathWithPackageName(opts.RequestPath) + if runF != nil { return runF(&opts) } @@ -689,3 +692,17 @@ func previewNamesToMIMETypes(names []string) string { } return strings.Join(types, ", ") } + +var pathWithPackageNameRE = regexp.MustCompile(`^\/(?:orgs|user|users)(?:\/.*)?\/packages\/(?:npm|maven|rubygems|docker|nuget|container)\/(?.*?)(?:\/(?:restore|versions)|$)`) + +func escapePathWithPackageName(path string) string { + matches := pathWithPackageNameRE.FindStringSubmatch(path) + if len(matches) > 0 { + i := pathWithPackageNameRE.SubexpIndex("package") + packageName := matches[i] + if packageName != "" { + return strings.Replace(path, packageName, url.QueryEscape(packageName), 1) + } + } + return path +} diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index a565312cd..7ba03932c 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -367,6 +367,72 @@ func Test_NewCmdApi(t *testing.T) { }, wantsErr: false, }, + { + name: "request path with container package name containing slashes", + cli: "/user/packages/container/github.com/username/package_name --verbose", + wants: ApiOptions{ + Hostname: "", + RequestMethod: "GET", + RequestMethodPassed: false, + RequestPath: "/user/packages/container/github.com%2Fusername%2Fpackage_name", + RequestInputFile: "", + RawFields: []string(nil), + MagicFields: []string(nil), + RequestHeaders: []string(nil), + ShowResponseHeaders: false, + Paginate: false, + Silent: false, + CacheTTL: 0, + Template: "", + FilterOutput: "", + Verbose: true, + }, + wantsErr: false, + }, + { + name: "request path with container package name containing slashes and restore", + cli: "/user/packages/container/github.com/username/package_name/restore --verbose", + wants: ApiOptions{ + Hostname: "", + RequestMethod: "GET", + RequestMethodPassed: false, + RequestPath: "/user/packages/container/github.com%2Fusername%2Fpackage_name/restore", + RequestInputFile: "", + RawFields: []string(nil), + MagicFields: []string(nil), + RequestHeaders: []string(nil), + ShowResponseHeaders: false, + Paginate: false, + Silent: false, + CacheTTL: 0, + Template: "", + FilterOutput: "", + Verbose: true, + }, + wantsErr: false, + }, + { + name: "request path with container package name containing slashes and versions", + cli: "/user/packages/container/github.com/username/package_name/versions --verbose", + wants: ApiOptions{ + Hostname: "", + RequestMethod: "GET", + RequestMethodPassed: false, + RequestPath: "/user/packages/container/github.com%2Fusername%2Fpackage_name/versions", + RequestInputFile: "", + RawFields: []string(nil), + MagicFields: []string(nil), + RequestHeaders: []string(nil), + ShowResponseHeaders: false, + Paginate: false, + Silent: false, + CacheTTL: 0, + Template: "", + FilterOutput: "", + Verbose: true, + }, + wantsErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From a29e74483a8cfe6ac0f0486280f436ddabbfc693 Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Fri, 7 Feb 2025 17:56:52 +0500 Subject: [PATCH 15/56] Update tests --- pkg/cmd/cache/delete/delete_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/cache/delete/delete_test.go b/pkg/cmd/cache/delete/delete_test.go index a9ec88dea..57ecb9381 100644 --- a/pkg/cmd/cache/delete/delete_test.go +++ b/pkg/cmd/cache/delete/delete_test.go @@ -217,7 +217,7 @@ func TestDeleteRun(t *testing.T) { }, { name: "no caches to delete when deleting all", - opts: DeleteOptions{Identifier: "123", DeleteAll: true}, + opts: DeleteOptions{DeleteAll: true}, stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), @@ -233,7 +233,7 @@ func TestDeleteRun(t *testing.T) { }, { name: "no caches to delete when deleting all but succeed on no cache tty", - opts: DeleteOptions{Identifier: "123", DeleteAll: true, SucceedOnNoCaches: true}, + opts: DeleteOptions{DeleteAll: true, SucceedOnNoCaches: true}, stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), @@ -249,7 +249,7 @@ func TestDeleteRun(t *testing.T) { }, { name: "no caches to delete when deleting all but succeed on no cache non-tty", - opts: DeleteOptions{Identifier: "123", DeleteAll: true, SucceedOnNoCaches: true}, + opts: DeleteOptions{DeleteAll: true, SucceedOnNoCaches: true}, stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"), @@ -261,7 +261,7 @@ func TestDeleteRun(t *testing.T) { }, tty: false, wantErr: false, - wantStdout: "", + wantStdout: "✓ No caches to delete\n", }, } From 17e7b57b3bb31630a747e408df9b160fec5a8a94 Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Fri, 7 Feb 2025 18:03:35 +0500 Subject: [PATCH 16/56] Use API without shorthand flag --- pkg/cmd/cache/delete/delete.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go index c05674170..a797eb607 100644 --- a/pkg/cmd/cache/delete/delete.go +++ b/pkg/cmd/cache/delete/delete.go @@ -90,7 +90,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co } cmd.Flags().BoolVarP(&opts.DeleteAll, "all", "a", false, "Delete all caches") - cmd.Flags().BoolVarP(&opts.SucceedOnNoCaches, "succeed-on-no-caches", "", false, "Return exit code 0 if no caches found. Must be used in conjunction with `--all`") + cmd.Flags().BoolVar(&opts.SucceedOnNoCaches, "succeed-on-no-caches", false, "Return exit code 0 if no caches found. Must be used in conjunction with `--all`") return cmd } From 49fd7a57564d7ed2525eaee3284959e3672952f7 Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Sat, 8 Feb 2025 18:20:43 +0500 Subject: [PATCH 17/56] [gh release create] Fail when there are no new commits since the most recent release --- pkg/cmd/release/create/create.go | 12 +++ pkg/cmd/release/create/create_test.go | 110 ++++++++++++++++++++++++++ pkg/cmd/release/create/http.go | 46 +++++++++++ pkg/cmd/release/shared/fetch.go | 8 +- 4 files changed, 172 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 8e77546d0..dddd3066f 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -60,6 +60,7 @@ type CreateOptions struct { NotesStartTag string VerifyTag bool NotesFromTag bool + OnlyNew bool } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -194,6 +195,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmdutil.NilBoolFlag(cmd, &opts.IsLatest, "latest", "", "Mark this release as \"Latest\" (default [automatic based on date and version]). --latest=false to explicitly NOT set as latest") cmd.Flags().BoolVarP(&opts.VerifyTag, "verify-tag", "", false, "Abort in case the git tag doesn't already exist in the remote repository") cmd.Flags().BoolVarP(&opts.NotesFromTag, "notes-from-tag", "", false, "Automatically generate notes from annotated tag") + cmd.Flags().BoolVar(&opts.OnlyNew, "only-new", false, "Create release only if there are new commits since the last release") _ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "target") @@ -211,6 +213,16 @@ func createRun(opts *CreateOptions) error { return err } + if opts.OnlyNew { + isNew, err := isNewRelease(httpClient, baseRepo) + if err != nil { + return err + } + if !isNew { + return fmt.Errorf("no new commits since the last release") + } + } + var existingTag bool if opts.TagName == "" { tags, err := getTags(httpClient, baseRepo, 5) diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index c9e9a8c8a..252d28cbc 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -63,6 +63,7 @@ func Test_NewCmdCreate(t *testing.T) { Concurrency: 5, Assets: []*shared.AssetForUpload(nil), VerifyTag: false, + OnlyNew: false, }, }, { @@ -87,6 +88,7 @@ func Test_NewCmdCreate(t *testing.T) { Concurrency: 5, Assets: []*shared.AssetForUpload(nil), VerifyTag: false, + OnlyNew: false, }, }, { @@ -347,6 +349,19 @@ func Test_NewCmdCreate(t *testing.T) { isTTY: false, wantErr: "using `--notes-from-tag` with `--generate-notes` or `--notes-start-tag` is not supported", }, + { + name: "with --only-new", + args: "v1.2.3 --only-new", + isTTY: false, + want: CreateOptions{ + TagName: "v1.2.3", + BodyProvided: false, + Concurrency: 5, + Assets: []*shared.AssetForUpload(nil), + NotesFromTag: false, + OnlyNew: true, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -402,6 +417,7 @@ func Test_NewCmdCreate(t *testing.T) { assert.Equal(t, tt.want.IsLatest, opts.IsLatest) assert.Equal(t, tt.want.VerifyTag, opts.VerifyTag) assert.Equal(t, tt.want.NotesFromTag, opts.NotesFromTag) + assert.Equal(t, tt.want.OnlyNew, opts.OnlyNew) require.Equal(t, len(tt.want.Assets), len(opts.Assets)) for i := range tt.want.Assets { @@ -460,6 +476,100 @@ func Test_createRun(t *testing.T) { wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", wantStderr: ``, }, + { + name: "create a release if there are new commits and the last release does not exist", + isTTY: true, + opts: CreateOptions{ + TagName: "v1.2.3", + Name: "The Big 1.2", + Body: "* Fixed bugs", + BodyProvided: true, + Target: "", + OnlyNew: true, + }, + runStubs: defaultRunStubs, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"), httpmock.StatusStringResponse(404, `{ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest/releases/releases#get-the-latest-release", + "status": "404" + }`)) + reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{ + "url": "https://api.github.com/releases/123", + "upload_url": "https://api.github.com/assets/upload", + "html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3" + }`, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "name": "The Big 1.2", + "body": "* Fixed bugs", + "draft": false, + "prerelease": false, + }, params) + })) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: ``, + }, + { + name: "create a release if there are new commits and the last release exists", + isTTY: true, + opts: CreateOptions{ + TagName: "v1.2.3", + Name: "The Big 1.2", + Body: "* Fixed bugs", + BodyProvided: true, + Target: "", + OnlyNew: true, + }, + runStubs: defaultRunStubs, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"), httpmock.StatusStringResponse(200, `{ + "tag_name": "v1.2.2" + }`)) + reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/compare/v1.2.2...HEAD"), httpmock.StatusStringResponse(200, `{ + "status": "ahead" + }`)) + reg.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.RESTPayload(201, `{ + "url": "https://api.github.com/releases/123", + "upload_url": "https://api.github.com/assets/upload", + "html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3" + }`, func(params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "tag_name": "v1.2.3", + "name": "The Big 1.2", + "body": "* Fixed bugs", + "draft": false, + "prerelease": false, + }, params) + })) + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: ``, + }, + { + name: "create a release if there are no new commits but the last release exists", + isTTY: true, + opts: CreateOptions{ + TagName: "v1.2.3", + Name: "The Big 1.2", + Body: "* Fixed bugs", + BodyProvided: true, + Target: "", + OnlyNew: true, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"), httpmock.StatusStringResponse(200, `{ + "tag_name": "v1.2.2" + }`)) + reg.Register(httpmock.REST("GET", "repos/OWNER/REPO/compare/v1.2.2...HEAD"), httpmock.StatusStringResponse(200, `{ + "status": "identical" + }`)) + }, + wantErr: "no new commits since the last release", + wantStdout: "", + wantStderr: ``, + }, { name: "with discussion category", isTTY: true, diff --git a/pkg/cmd/release/create/http.go b/pkg/cmd/release/create/http.go index 3bb55f39e..0db8b1c0a 100644 --- a/pkg/cmd/release/create/http.go +++ b/pkg/cmd/release/create/http.go @@ -2,6 +2,7 @@ package create import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -299,3 +300,48 @@ func tokenHasWorkflowScope(resp *http.Response) bool { return slices.Contains(strings.Split(scopes, ","), "workflow") } + +// isNewRelease checks if there are new commits since the latest release. +func isNewRelease(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { + ctx := context.Background() + release, err := shared.FetchLatestRelease(ctx, httpClient, repo) + if err != nil { + if errors.Is(err, shared.ErrReleaseNotFound) { + return true, nil + } else { + return false, err + } + } + + tagName := release.TagName + path := fmt.Sprintf("repos/%s/%s/compare/%s...HEAD?per_page=1", repo.RepoOwner(), repo.RepoName(), tagName) + url := ghinstance.RESTPrefix(repo.RepoHost()) + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + resp, err := httpClient.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return false, api.HandleHTTPError(resp) + } + + type comparisonStatus struct { + Status string `json:"status"` + } + + var cmpStatus comparisonStatus + if err := json.NewDecoder(resp.Body).Decode(&cmpStatus); err != nil { + return false, err + } + + isNew := cmpStatus.Status == "ahead" + return isNew, nil +} diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index c9cf7a6a4..8db7e502a 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -124,7 +124,7 @@ func (rel *Release) ExportData(fields []string) map[string]interface{} { return data } -var errNotFound = errors.New("release not found") +var ErrReleaseNotFound = errors.New("release not found") type fetchResult struct { release *Release @@ -150,7 +150,7 @@ func FetchRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Inte }() res := <-results - if errors.Is(res.error, errNotFound) { + if errors.Is(res.error, ErrReleaseNotFound) { res = <-results cancel() // satisfy the linter even though no goroutines are running anymore } else { @@ -190,7 +190,7 @@ func fetchDraftRelease(ctx context.Context, httpClient *http.Client, repo ghrepo } if query.Repository.Release == nil || !query.Repository.Release.IsDraft { - return nil, errNotFound + return nil, ErrReleaseNotFound } // Then, use REST to get information about the draft release. In theory, we could have fetched @@ -213,7 +213,7 @@ func fetchReleasePath(ctx context.Context, httpClient *http.Client, host string, if resp.StatusCode == 404 { _, _ = io.Copy(io.Discard, resp.Body) - return nil, errNotFound + return nil, ErrReleaseNotFound } else if resp.StatusCode > 299 { return nil, api.HandleHTTPError(resp) } From e516e5ed5db056653dbb1dd141e4c714555a5967 Mon Sep 17 00:00:00 2001 From: latzskim Date: Wed, 12 Feb 2025 21:04:22 +0100 Subject: [PATCH 18/56] [gh issue/pr comment] Create a comment if no comment already --- pkg/cmd/issue/comment/comment.go | 14 +-- pkg/cmd/issue/comment/comment_test.go | 127 ++++++++++++++++++++++--- pkg/cmd/pr/comment/comment.go | 14 +-- pkg/cmd/pr/comment/comment_test.go | 131 +++++++++++++++++++++++--- pkg/cmd/pr/shared/commentable.go | 61 +++++++++--- 5 files changed, 297 insertions(+), 50 deletions(-) diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index c91cf79c9..090b0748c 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -11,12 +11,13 @@ import ( func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) error) *cobra.Command { opts := &prShared.CommentableOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - EditSurvey: prShared.CommentableEditSurvey(f.Config, f.IOStreams), - InteractiveEditSurvey: prShared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams), - ConfirmSubmitSurvey: prShared.CommentableConfirmSubmitSurvey(f.Prompter), - OpenInBrowser: f.Browser.Browse, + IO: f.IOStreams, + HttpClient: f.HttpClient, + EditSurvey: prShared.CommentableEditSurvey(f.Config, f.IOStreams), + InteractiveEditSurvey: prShared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams), + ConfirmSubmitSurvey: prShared.CommentableConfirmSubmitSurvey(f.Prompter), + ConfirmCreateIfNoneSurvey: prShared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter), + OpenInBrowser: f.Browser.Browse, } var bodyFile string @@ -69,6 +70,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e cmd.Flags().BoolP("editor", "e", false, "Skip prompts and open the text editor to write the body in") cmd.Flags().BoolP("web", "w", false, "Open the web browser to write the comment") cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the same author") + cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last") return cmd } diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index 668d758e5..461ae1065 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -109,6 +109,29 @@ func TestNewCmdComment(t *testing.T) { }, wantsErr: false, }, + { + name: "edit last flag", + input: "1 --edit-last", + output: shared.CommentableOptions{ + Interactive: true, + InputType: shared.InputTypeEditor, + Body: "", + EditLast: true, + }, + wantsErr: false, + }, + { + name: "edit last flag with create if none", + input: "1 --edit-last --create-if-none", + output: shared.CommentableOptions{ + Interactive: true, + InputType: shared.InputTypeEditor, + Body: "", + EditLast: true, + CreateIfNone: true, + }, + wantsErr: false, + }, { name: "body and body-file flags", input: "1 --body 'test' --body-file 'test-file.txt'", @@ -139,6 +162,12 @@ func TestNewCmdComment(t *testing.T) { output: shared.CommentableOptions{}, wantsErr: true, }, + { + name: "create-if-none flag without edit-last", + input: "1 --create-if-none", + output: shared.CommentableOptions{}, + wantsErr: true, + }, } for _, tt := range tests { @@ -188,11 +217,12 @@ func TestNewCmdComment(t *testing.T) { func Test_commentRun(t *testing.T) { tests := []struct { - name string - input *shared.CommentableOptions - httpStubs func(*testing.T, *httpmock.Registry) - stdout string - stderr string + name string + input *shared.CommentableOptions + emptyComments bool + httpStubs func(*testing.T, *httpmock.Registry) + stdout string + stderr string }{ { name: "interactive editor", @@ -225,6 +255,24 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n", }, + { + name: "interactive editor with edit last and create if none", + input: &shared.CommentableOptions{ + Interactive: true, + InputType: 0, + Body: "", + EditLast: true, + CreateIfNone: true, + + InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil }, + ConfirmCreateIfNoneSurvey: func() (bool, error) { return true, nil }, + ConfirmSubmitSurvey: func() (bool, error) { return true, nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentUpdate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n", + }, { name: "non-interactive web", input: &shared.CommentableOptions{ @@ -248,6 +296,39 @@ func Test_commentRun(t *testing.T) { }, stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n", }, + { + name: "non-interactive web with edit last and create if none for empty comments", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeWeb, + Body: "", + EditLast: true, + CreateIfNone: true, + + OpenInBrowser: func(u string) error { + assert.Contains(t, u, "#issuecomment-new") + return nil + }, + }, + emptyComments: true, + stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n", + }, + { + name: "non-interactive web with edit last and create if none", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeWeb, + Body: "", + EditLast: true, + CreateIfNone: true, + + OpenInBrowser: func(u string) error { + assert.Contains(t, u, "#issuecomment-111") + return nil + }, + }, + stderr: "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n", + }, { name: "non-interactive editor", input: &shared.CommentableOptions{ @@ -277,6 +358,23 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n", }, + { + name: "non-interactive editor with edit last and create if none", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeEditor, + Body: "", + EditLast: true, + CreateIfNone: true, + + EditSurvey: func(string) (string, error) { return "comment body", nil }, + }, + emptyComments: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentCreate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", + }, { name: "non-interactive inline", input: &shared.CommentableOptions{ @@ -319,14 +417,21 @@ func Test_commentRun(t *testing.T) { tt.input.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + + comments := api.Comments{Nodes: []api.Comment{ + {ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-111", ViewerDidAuthor: true}, + {ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-222"}, + }} + + if tt.emptyComments { + comments.Nodes = []api.Comment{} + } + tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) { return &api.Issue{ - ID: "ISSUE-ID", - URL: "https://github.com/OWNER/REPO/issues/123", - Comments: api.Comments{Nodes: []api.Comment{ - {ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-111", ViewerDidAuthor: true}, - {ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-222"}, - }}, + ID: "ISSUE-ID", + URL: "https://github.com/OWNER/REPO/issues/123", + Comments: comments, }, ghrepo.New("OWNER", "REPO"), nil } diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go index 31a2bc25c..a2ab4bf9e 100644 --- a/pkg/cmd/pr/comment/comment.go +++ b/pkg/cmd/pr/comment/comment.go @@ -10,12 +10,13 @@ import ( func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) error) *cobra.Command { opts := &shared.CommentableOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - EditSurvey: shared.CommentableEditSurvey(f.Config, f.IOStreams), - InteractiveEditSurvey: shared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams), - ConfirmSubmitSurvey: shared.CommentableConfirmSubmitSurvey(f.Prompter), - OpenInBrowser: f.Browser.Browse, + IO: f.IOStreams, + HttpClient: f.HttpClient, + EditSurvey: shared.CommentableEditSurvey(f.Config, f.IOStreams), + InteractiveEditSurvey: shared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams), + ConfirmSubmitSurvey: shared.CommentableConfirmSubmitSurvey(f.Prompter), + ConfirmCreateIfNoneSurvey: shared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter), + OpenInBrowser: f.Browser.Browse, } var bodyFile string @@ -75,6 +76,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err cmd.Flags().BoolP("editor", "e", false, "Skip prompts and open the text editor to write the body in") cmd.Flags().BoolP("web", "w", false, "Open the web browser to write the comment") cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the same author") + cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last") return cmd } diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go index 56cf58d21..a6cc36abf 100644 --- a/pkg/cmd/pr/comment/comment_test.go +++ b/pkg/cmd/pr/comment/comment_test.go @@ -129,6 +129,29 @@ func TestNewCmdComment(t *testing.T) { }, wantsErr: false, }, + { + name: "edit last flag", + input: "1 --edit-last", + output: shared.CommentableOptions{ + Interactive: true, + InputType: shared.InputTypeEditor, + Body: "", + EditLast: true, + }, + wantsErr: false, + }, + { + name: "edit last flag with create if none", + input: "1 --edit-last --create-if-none", + output: shared.CommentableOptions{ + Interactive: true, + InputType: shared.InputTypeEditor, + Body: "", + EditLast: true, + CreateIfNone: true, + }, + wantsErr: false, + }, { name: "body and body-file flags", input: "1 --body 'test' --body-file 'test-file.txt'", @@ -159,6 +182,12 @@ func TestNewCmdComment(t *testing.T) { output: shared.CommentableOptions{}, wantsErr: true, }, + { + name: "create-if-none flag without edit-last", + input: "1 --create-if-none", + output: shared.CommentableOptions{}, + wantsErr: true, + }, } for _, tt := range tests { @@ -208,11 +237,12 @@ func TestNewCmdComment(t *testing.T) { func Test_commentRun(t *testing.T) { tests := []struct { - name string - input *shared.CommentableOptions - httpStubs func(*testing.T, *httpmock.Registry) - stdout string - stderr string + name string + input *shared.CommentableOptions + emptyComments bool + httpStubs func(*testing.T, *httpmock.Registry) + stdout string + stderr string }{ { name: "interactive editor", @@ -245,6 +275,24 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n", }, + { + name: "interactive editor with edit last and create if none", + input: &shared.CommentableOptions{ + Interactive: true, + InputType: 0, + Body: "", + EditLast: true, + CreateIfNone: true, + + InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil }, + ConfirmCreateIfNoneSurvey: func() (bool, error) { return true, nil }, + ConfirmSubmitSurvey: func() (bool, error) { return true, nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentUpdate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n", + }, { name: "non-interactive web", input: &shared.CommentableOptions{ @@ -264,7 +312,43 @@ func Test_commentRun(t *testing.T) { Body: "", EditLast: true, - OpenInBrowser: func(string) error { return nil }, + OpenInBrowser: func(u string) error { + assert.Contains(t, u, "#issuecomment-111") + return nil + }, + }, + stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n", + }, + { + name: "non-interactive web with edit last and create if none for empty comments", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeWeb, + Body: "", + EditLast: true, + CreateIfNone: true, + + OpenInBrowser: func(u string) error { + assert.Contains(t, u, "#issuecomment-new") + return nil + }, + }, + emptyComments: true, + stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n", + }, + { + name: "non-interactive web with edit last and create if none", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeWeb, + Body: "", + EditLast: true, + CreateIfNone: true, + + OpenInBrowser: func(u string) error { + assert.Contains(t, u, "#issuecomment-111") + return nil + }, }, stderr: "Opening https://github.com/OWNER/REPO/pull/123 in your browser.\n", }, @@ -297,6 +381,23 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n", }, + { + name: "non-interactive editor with edit last and create if none", + input: &shared.CommentableOptions{ + Interactive: false, + InputType: shared.InputTypeEditor, + Body: "", + EditLast: true, + CreateIfNone: true, + + EditSurvey: func(string) (string, error) { return "comment body", nil }, + }, + emptyComments: true, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentCreate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", + }, { name: "non-interactive inline", input: &shared.CommentableOptions{ @@ -339,14 +440,20 @@ func Test_commentRun(t *testing.T) { tt.input.IO = ios tt.input.HttpClient = httpClient + + comments := api.Comments{Nodes: []api.Comment{ + {ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true}, + {ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-222"}, + }} + if tt.emptyComments { + comments.Nodes = []api.Comment{} + } + tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) { return &api.PullRequest{ - Number: 123, - URL: "https://github.com/OWNER/REPO/pull/123", - Comments: api.Comments{Nodes: []api.Comment{ - {ID: "id1", Author: api.CommentAuthor{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true}, - {ID: "id2", Author: api.CommentAuthor{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-222"}, - }}, + Number: 123, + URL: "https://github.com/OWNER/REPO/pull/123", + Comments: comments, }, ghrepo.New("OWNER", "REPO"), nil } diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go index 7a38286d2..a860d9ccb 100644 --- a/pkg/cmd/pr/shared/commentable.go +++ b/pkg/cmd/pr/shared/commentable.go @@ -17,6 +17,8 @@ import ( "github.com/spf13/cobra" ) +var ErrNoUserComments = errors.New("no comments found for current user") + type InputType int const ( @@ -32,19 +34,21 @@ type Commentable interface { } type CommentableOptions struct { - IO *iostreams.IOStreams - HttpClient func() (*http.Client, error) - RetrieveCommentable func() (Commentable, ghrepo.Interface, error) - EditSurvey func(string) (string, error) - InteractiveEditSurvey func(string) (string, error) - ConfirmSubmitSurvey func() (bool, error) - OpenInBrowser func(string) error - Interactive bool - InputType InputType - Body string - EditLast bool - Quiet bool - Host string + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + RetrieveCommentable func() (Commentable, ghrepo.Interface, error) + EditSurvey func(string) (string, error) + InteractiveEditSurvey func(string) (string, error) + ConfirmSubmitSurvey func() (bool, error) + ConfirmCreateIfNoneSurvey func() (bool, error) + OpenInBrowser func(string) error + Interactive bool + InputType InputType + Body string + EditLast bool + CreateIfNone bool + Quiet bool + Host string } func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error { @@ -66,6 +70,10 @@ func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error { inputFlags++ } + if opts.CreateIfNone && !opts.EditLast { + return cmdutil.FlagErrorf("`--create-if-none` can only be used with `--edit-last`") + } + if inputFlags == 0 { if !opts.IO.CanPrompt() { return cmdutil.FlagErrorf("flags required when not running interactively") @@ -85,7 +93,24 @@ func CommentableRun(opts *CommentableOptions) error { } opts.Host = repo.RepoHost() if opts.EditLast { - return updateComment(commentable, opts) + err := updateComment(commentable, opts) + if !errors.Is(err, ErrNoUserComments) { + return err + } + + if opts.Interactive { + if opts.CreateIfNone { + fmt.Fprintln(opts.IO.ErrOut, "No comments found. Creating a new comment.") + } else { + cont, err := opts.ConfirmCreateIfNoneSurvey() + if err != nil { + return err + } + if !cont { + return ErrNoUserComments + } + } + } } return createComment(commentable, opts) } @@ -144,7 +169,7 @@ func createComment(commentable Commentable, opts *CommentableOptions) error { func updateComment(commentable Commentable, opts *CommentableOptions) error { comments := commentable.CurrentUserComments() if len(comments) == 0 { - return fmt.Errorf("no comments found for current user") + return ErrNoUserComments } lastComment := &comments[len(comments)-1] @@ -219,6 +244,12 @@ func CommentableInteractiveEditSurvey(cf func() (gh.Config, error), io *iostream } } +func CommentableInteractiveCreateIfNoneSurvey(p Prompt) func() (bool, error) { + return func() (bool, error) { + return p.Confirm("No comments found. Create one?", true) + } +} + func CommentableEditSurvey(cf func() (gh.Config, error), io *iostreams.IOStreams) func(string) (string, error) { return func(initialValue string) (string, error) { editorCommand, err := cmdutil.DetermineEditor(cf) From 026f64ce6fbff411f7c18308f0d9aac63dffacd0 Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Thu, 13 Feb 2025 10:41:53 +0500 Subject: [PATCH 19/56] Standardize URLs --- pkg/cmd/api/api.go | 4 ++-- pkg/cmd/extension/command.go | 2 +- pkg/cmd/repo/rename/rename.go | 4 ++-- pkg/cmd/root/help_topic.go | 2 +- pkg/cmd/search/code/code.go | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index a1308d09f..51febdfc9 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -201,12 +201,12 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command Annotations: map[string]string{ "help:environment": heredoc.Doc(` GH_TOKEN, GITHUB_TOKEN (in order of precedence): an authentication token for - github.com API requests. + API requests. GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN (in order of precedence): an authentication token for API requests to GitHub Enterprise. - GH_HOST: make the request to a GitHub host other than github.com. + GH_HOST: make the request to a GitHub host other than . `), }, Args: cobra.ExactArgs(1), diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 93c385374..1c930fe5a 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -300,7 +300,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { For GitHub repositories, the repository argument can be specified in %[1]sOWNER/REPO%[1]s format or as a full repository URL. - The URL format is useful when the repository is not hosted on github.com. + The URL format is useful when the repository is not hosted on . For remote repositories, the GitHub CLI first looks for the release artifacts assuming that it's a binary extension i.e. prebuilt binaries provided as part of the release. diff --git a/pkg/cmd/repo/rename/rename.go b/pkg/cmd/repo/rename/rename.go index e50095d76..5656dd046 100644 --- a/pkg/cmd/repo/rename/rename.go +++ b/pkg/cmd/repo/rename/rename.go @@ -59,7 +59,7 @@ func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Co with %[1]s--repo%[1]s is renamed. To transfer repository ownership to another user account or organization, - you must follow additional steps on GitHub.com + you must follow additional steps on . For more information on transferring repository ownership, see: @@ -126,7 +126,7 @@ func renameRun(opts *RenameOptions) error { } if strings.Contains(newRepoName, "/") { - return fmt.Errorf("New repository name cannot contain '/' character - to transfer a repository to a new owner, you must follow additional steps on GitHub.com. For more information on transferring repository ownership, see .") + return fmt.Errorf("New repository name cannot contain '/' character - to transfer a repository to a new owner, you must follow additional steps on . For more information on transferring repository ownership, see .") } if opts.DoConfirm { diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index 9ae2c5c86..00e9cf552 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -43,7 +43,7 @@ var HelpTopics = []helpTopic{ short: "Environment variables that can be used with gh", long: heredoc.Docf(` %[1]sGH_TOKEN%[1]s, %[1]sGITHUB_TOKEN%[1]s (in order of precedence): an authentication token that will be used when - a command targets either github.com or a subdomain of ghe.com. Setting this avoids being prompted to + a command targets either or a subdomain of . Setting this avoids being prompted to authenticate and takes precedence over previously stored credentials. %[1]sGH_ENTERPRISE_TOKEN%[1]s, %[1]sGITHUB_ENTERPRISE_TOKEN%[1]s (in order of precedence): an authentication diff --git a/pkg/cmd/search/code/code.go b/pkg/cmd/search/code/code.go index 761d2602c..bec29d3cf 100644 --- a/pkg/cmd/search/code/code.go +++ b/pkg/cmd/search/code/code.go @@ -40,7 +40,7 @@ func NewCmdCode(f *cmdutil.Factory, runF func(*CodeOptions) error) *cobra.Comman Note that these search results are powered by what is now a legacy GitHub code search engine. - The results might not match what is seen on github.com, and new features like regex search + The results might not match what is seen on , and new features like regex search are not yet available via the GitHub API. `), Example: heredoc.Doc(` From 8b7fb231ec1414cde5e8b8164fab14254f73d55f Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Thu, 13 Feb 2025 11:06:27 +0500 Subject: [PATCH 20/56] Remove trailing whitespace --- pkg/cmd/actions/actions.go | 14 +++++++------- pkg/cmd/auth/login/login.go | 2 +- pkg/cmd/codespace/rebuild.go | 2 +- pkg/cmd/codespace/ssh.go | 6 +++--- pkg/cmd/codespace/view.go | 2 +- pkg/cmd/completion/completion.go | 6 +++--- pkg/cmd/gist/list/list_test.go | 10 +++++----- pkg/cmd/label/list_test.go | 2 +- pkg/cmd/pr/close/close.go | 2 +- pkg/cmd/pr/close/close_test.go | 2 +- pkg/cmd/project/item-edit/item_edit.go | 2 +- pkg/cmd/release/create/create.go | 2 +- pkg/cmd/release/view/view_test.go | 16 ++++++++-------- pkg/cmd/repo/delete/delete.go | 4 ++-- pkg/cmd/repo/deploy-key/add/add.go | 2 +- pkg/cmd/repo/gitignore/view/view.go | 2 +- pkg/cmd/repo/license/list/list.go | 2 +- pkg/cmd/repo/license/view/view_test.go | 2 +- pkg/cmd/repo/rename/rename.go | 6 +++--- pkg/cmd/ruleset/list/list.go | 2 +- pkg/cmd/ruleset/view/view.go | 2 +- pkg/cmd/ruleset/view/view_test.go | 12 ++++++------ pkg/cmd/run/rerun/rerun.go | 2 +- pkg/cmd/search/code/code.go | 4 ++-- 24 files changed, 54 insertions(+), 54 deletions(-) diff --git a/pkg/cmd/actions/actions.go b/pkg/cmd/actions/actions.go index 663866844..76d72b954 100644 --- a/pkg/cmd/actions/actions.go +++ b/pkg/cmd/actions/actions.go @@ -34,19 +34,19 @@ func actionsExplainer(cs *iostreams.ColorScheme) string { GitHub CLI integrates with GitHub Actions to help you manage runs and workflows. %[3]s - gh run list: List recent workflow runs - gh run view: View details for a workflow run or one of its jobs - gh run watch: Watch a workflow run while it executes - gh run rerun: Rerun a failed workflow run + gh run list: List recent workflow runs + gh run view: View details for a workflow run or one of its jobs + gh run watch: Watch a workflow run while it executes + gh run rerun: Rerun a failed workflow run gh run download: Download artifacts generated by runs To see more help, run %[1]sgh help run %[1]s %[4]s gh workflow list: List workflow files in your repository - gh workflow view: View details for a workflow file - gh workflow enable: Enable a workflow file - gh workflow disable: Disable a workflow file + gh workflow view: View details for a workflow file + gh workflow enable: Enable a workflow file + gh workflow disable: Disable a workflow file gh workflow run: Trigger a workflow_dispatch run for a workflow file To see more help, run %[1]sgh help workflow %[1]s diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 347fcdb6a..95e32b799 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -72,7 +72,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm The minimum required scopes for the token are: %[1]srepo%[1]s, %[1]sread:org%[1]s, and %[1]sgist%[1]s. Take care when passing a fine-grained personal access token to %[1]s--with-token%[1]s as the inherent scoping to certain resources may cause confusing behaviour when interacting with other - resources. Favour setting %[1]sGH_TOKEN$%[1]s for fine-grained personal access token usage. + resources. Favour setting %[1]sGH_TOKEN$%[1]s for fine-grained personal access token usage. Alternatively, gh will use the authentication token found in environment variables. This method is most suitable for "headless" use of gh such as in automation. See diff --git a/pkg/cmd/codespace/rebuild.go b/pkg/cmd/codespace/rebuild.go index 565406edc..8566c2211 100644 --- a/pkg/cmd/codespace/rebuild.go +++ b/pkg/cmd/codespace/rebuild.go @@ -23,7 +23,7 @@ func newRebuildCmd(app *App) *cobra.Command { Short: "Rebuild a codespace", Long: heredoc.Doc(` Rebuilding recreates your codespace. - + Your code and any current changes will be preserved. Your codespace will be rebuilt using your working directory's dev container. A full rebuild also removes cached Docker images. `), diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go index b79ec68c9..70e7ceb42 100644 --- a/pkg/cmd/codespace/ssh.go +++ b/pkg/cmd/codespace/ssh.go @@ -55,7 +55,7 @@ func newSSHCmd(app *App) *cobra.Command { Long: heredoc.Docf(` The %[1]sssh%[1]s command is used to SSH into a codespace. In its simplest form, you can run %[1]sgh cs ssh%[1]s, select a codespace interactively, and connect. - + The %[1]sssh%[1]s command will automatically create a public/private ssh key pair in the %[1]s~/.ssh%[1]s directory if you do not have an existing valid key pair. When selecting the key pair to use, the preferred order is: @@ -732,8 +732,8 @@ func newCpCmd(app *App) *cobra.Command { be evaluated on the remote machine, subject to expansion of tildes, braces, globs, environment variables, and backticks. For security, do not use this flag with arguments provided by untrusted users; see for discussion. - - By default, the %[1]scp%[1]s command will create a public/private ssh key pair to authenticate with + + By default, the %[1]scp%[1]s command will create a public/private ssh key pair to authenticate with the codespace inside the %[1]s~/.ssh directory%[1]s. `, "`"), Example: heredoc.Doc(` diff --git a/pkg/cmd/codespace/view.go b/pkg/cmd/codespace/view.go index e212f0820..205d54673 100644 --- a/pkg/cmd/codespace/view.go +++ b/pkg/cmd/codespace/view.go @@ -30,7 +30,7 @@ func newViewCmd(app *App) *cobra.Command { Short: "View details about a codespace", Example: heredoc.Doc(` # select a codespace from a list of all codespaces you own - $ gh cs view + $ gh cs view # view the details of a specific codespace $ gh cs view -c codespace-name-12345 diff --git a/pkg/cmd/completion/completion.go b/pkg/cmd/completion/completion.go index d83dd31ef..b34bf7abf 100644 --- a/pkg/cmd/completion/completion.go +++ b/pkg/cmd/completion/completion.go @@ -33,7 +33,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { After, add this to your %[1]s~/.bash_profile%[1]s: eval "$(gh completion -s bash)" - + ### zsh Generate a %[1]s_gh%[1]s completion script and put it somewhere in your %[1]s$fpath%[1]s: @@ -44,7 +44,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { autoload -U compinit compinit -i - + Zsh version 5.7 or later is recommended. ### fish @@ -59,7 +59,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { mkdir -Path (Split-Path -Parent $profile) -ErrorAction SilentlyContinue notepad $profile - + Add the line and save the file: Invoke-Expression -Command $(gh completion -s powershell | Out-String) diff --git a/pkg/cmd/gist/list/list_test.go b/pkg/cmd/gist/list/list_test.go index e6cb35d53..0acfae109 100644 --- a/pkg/cmd/gist/list/list_test.go +++ b/pkg/cmd/gist/list/list_test.go @@ -540,14 +540,14 @@ func Test_listRun(t *testing.T) { wantOut: heredoc.Doc(` 1234 main.txt octo match in the description - + 2345 octo.txt match in the file name 3456 main.txt match in the file text octo in the text - + `), }, { @@ -599,14 +599,14 @@ func Test_listRun(t *testing.T) { wantOut: heredoc.Docf(` %[1]s[0;34m1234%[1]s[0m %[1]s[0;32mmain.txt%[1]s[0m %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;1;39m match in the description%[1]s[0m - + %[1]s[0;34m2345%[1]s[0m %[1]s[0;30;43mocto%[1]s[0m%[1]s[0;32m.txt%[1]s[0m %[1]s[0;1;39mmatch in the file name%[1]s[0m - + %[1]s[0;34m3456%[1]s[0m %[1]s[0;32mmain.txt%[1]s[0m %[1]s[0;1;39mmatch in the file text%[1]s[0m %[1]s[0;30;43mocto%[1]s[0m in the text - + `, "\x1b"), }, } diff --git a/pkg/cmd/label/list_test.go b/pkg/cmd/label/list_test.go index 6f066da32..8a1bc7c7a 100644 --- a/pkg/cmd/label/list_test.go +++ b/pkg/cmd/label/list_test.go @@ -218,7 +218,7 @@ func TestListRun(t *testing.T) { wantStdout: heredoc.Doc(` Showing 2 of 2 labels in OWNER/REPO - + NAME DESCRIPTION COLOR bug This is a bug label #d73a4a docs This is a docs label #ffa8da diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go index ec5132377..063501ef7 100644 --- a/pkg/cmd/pr/close/close.go +++ b/pkg/cmd/pr/close/close.go @@ -143,7 +143,7 @@ func closeRun(opts *CloseOptions) error { branchSwitchString = fmt.Sprintf(" and switched to branch %s", cs.Cyan(branchToSwitchTo)) } } else { - fmt.Fprintf(opts.IO.ErrOut, "%s Skipped deleting the local branch since current directory is not a git repository \n", cs.WarningIcon()) + fmt.Fprintf(opts.IO.ErrOut, "%s Skipped deleting the local branch since current directory is not a git repository\n", cs.WarningIcon()) } } diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go index f957581f8..959af0e04 100644 --- a/pkg/cmd/pr/close/close_test.go +++ b/pkg/cmd/pr/close/close_test.go @@ -271,7 +271,7 @@ func TestPrClose_deleteBranch_notInGitRepo(t *testing.T) { assert.Equal(t, "", output.String()) assert.Equal(t, heredoc.Doc(` ✓ Closed pull request OWNER/REPO#96 (The title of the PR) - ! Skipped deleting the local branch since current directory is not a git repository + ! Skipped deleting the local branch since current directory is not a git repository ✓ Deleted branch trunk `), output.Stderr()) } diff --git a/pkg/cmd/project/item-edit/item_edit.go b/pkg/cmd/project/item-edit/item_edit.go index 657af1f54..c4acaaea9 100644 --- a/pkg/cmd/project/item-edit/item_edit.go +++ b/pkg/cmd/project/item-edit/item_edit.go @@ -63,7 +63,7 @@ func NewCmdEditItem(f *cmdutil.Factory, runF func(config editItemConfig) error) Short: "Edit an item in a project", Long: heredoc.Docf(` Edit either a draft issue or a project item. Both usages require the ID of the item to edit. - + For non-draft issues, the ID of the project is also required, and only a single field value can be updated per invocation. Remove project item field value using %[1]s--clear%[1]s flag. diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 8e77546d0..6da461a88 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -120,7 +120,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co $ gh release create v1.2.3 --notes-from-tag Don't mark the release as latest - $ gh release create v1.2.3 --latest=false + $ gh release create v1.2.3 --latest=false Upload all tarballs in a directory as release assets $ gh release create v1.2.3 ./dist/*.tgz diff --git a/pkg/cmd/release/view/view_test.go b/pkg/cmd/release/view/view_test.go index 6fc747f69..1fed0fdc8 100644 --- a/pkg/cmd/release/view/view_test.go +++ b/pkg/cmd/release/view/view_test.go @@ -144,15 +144,15 @@ func Test_viewRun(t *testing.T) { wantStdout: heredoc.Doc(` v1.2.3 MonaLisa released this about 1 day ago - + • Fixed bugs - - + + Assets windows.zip 12 B linux.tgz 34 B - + View on GitHub: https://github.com/OWNER/REPO/releases/tags/v1.2.3 `), wantStderr: ``, @@ -168,15 +168,15 @@ func Test_viewRun(t *testing.T) { wantStdout: heredoc.Doc(` v1.2.3 MonaLisa released this about 1 day ago - + • Fixed bugs - - + + Assets windows.zip 12 B linux.tgz 34 B - + View on GitHub: https://github.com/OWNER/REPO/releases/tags/v1.2.3 `), wantStderr: ``, diff --git a/pkg/cmd/repo/delete/delete.go b/pkg/cmd/repo/delete/delete.go index 7c6476f1d..50df0ddac 100644 --- a/pkg/cmd/repo/delete/delete.go +++ b/pkg/cmd/repo/delete/delete.go @@ -41,10 +41,10 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co Short: "Delete a repository", Long: heredoc.Docf(` Delete a GitHub repository. - + With no argument, deletes the current repository. Otherwise, deletes the specified repository. - Deletion requires authorization with the %[1]sdelete_repo%[1]s scope. + Deletion requires authorization with the %[1]sdelete_repo%[1]s scope. To authorize, run %[1]sgh auth refresh -s delete_repo%[1]s `, "`"), Args: cobra.MaximumNArgs(1), diff --git a/pkg/cmd/repo/deploy-key/add/add.go b/pkg/cmd/repo/deploy-key/add/add.go index 473d42498..dbac7dcfb 100644 --- a/pkg/cmd/repo/deploy-key/add/add.go +++ b/pkg/cmd/repo/deploy-key/add/add.go @@ -34,7 +34,7 @@ func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command Short: "Add a deploy key to a GitHub repository", Long: heredoc.Doc(` Add a deploy key to a GitHub repository. - + Note that any key added by gh will be associated with the current authentication token. If you de-authorize the GitHub CLI app or authentication token from your account, any deploy keys added by GitHub CLI will be removed as well. diff --git a/pkg/cmd/repo/gitignore/view/view.go b/pkg/cmd/repo/gitignore/view/view.go index 46e674632..b22f94ed9 100644 --- a/pkg/cmd/repo/gitignore/view/view.go +++ b/pkg/cmd/repo/gitignore/view/view.go @@ -34,7 +34,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman Short: "View an available repository gitignore template", Long: heredoc.Docf(` View an available repository %[1]s.gitignore%[1]s template. - + %[1]s