From a78c06970a6b351da65f449d61278b0838e774a5 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Mon, 24 Mar 2025 17:28:00 -0600 Subject: [PATCH 001/249] pass predicate type to get attestation api methods Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/api/client.go | 21 ++++++++++-------- pkg/cmd/attestation/api/client_test.go | 30 +++++++++++++------------- pkg/cmd/attestation/api/mock_client.go | 20 ++++++++--------- 3 files changed, 37 insertions(+), 34 deletions(-) diff --git a/pkg/cmd/attestation/api/client.go b/pkg/cmd/attestation/api/client.go index 1e99a2a06..8c1af495f 100644 --- a/pkg/cmd/attestation/api/client.go +++ b/pkg/cmd/attestation/api/client.go @@ -39,8 +39,8 @@ type httpClient interface { } type Client interface { - GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error) - GetByOwnerAndDigest(owner, digest string, limit int) ([]*Attestation, error) + GetByRepoAndDigest(repo, digest, predicateType string, limit int) ([]*Attestation, error) + GetByOwnerAndDigest(owner, digest, predicateType string, limit int) ([]*Attestation, error) GetTrustDomain() (string, error) } @@ -61,21 +61,21 @@ func NewLiveClient(hc *http.Client, host string, l *ioconfig.Handler) *LiveClien } // GetByRepoAndDigest fetches the attestation by repo and digest -func (c *LiveClient) GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error) { +func (c *LiveClient) GetByRepoAndDigest(repo, digest, predicateType string, limit int) ([]*Attestation, error) { c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", digest) url := fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, repo, digest) - return c.getByURL(url, limit) + return c.getByURL(url, predicateType, limit) } // GetByOwnerAndDigest fetches attestation by owner and digest -func (c *LiveClient) GetByOwnerAndDigest(owner, digest string, limit int) ([]*Attestation, error) { +func (c *LiveClient) GetByOwnerAndDigest(owner, digest, predicateType string, limit int) ([]*Attestation, error) { c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", digest) url := fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, owner, digest) - return c.getByURL(url, limit) + return c.getByURL(url, predicateType, limit) } -func (c *LiveClient) getByURL(url string, limit int) ([]*Attestation, error) { - attestations, err := c.getAttestations(url, limit) +func (c *LiveClient) getByURL(url, predicateType string, limit int) ([]*Attestation, error) { + attestations, err := c.getAttestations(url, predicateType, limit) if err != nil { return nil, err } @@ -94,7 +94,7 @@ func (c *LiveClient) GetTrustDomain() (string, error) { return c.getTrustDomain(MetaPath) } -func (c *LiveClient) getAttestations(url string, limit int) ([]*Attestation, error) { +func (c *LiveClient) getAttestations(url, predicateType string, limit int) ([]*Attestation, error) { perPage := limit if perPage <= 0 || perPage > maxLimitForFlag { return nil, fmt.Errorf("limit must be greater than 0 and less than or equal to %d", maxLimitForFlag) @@ -106,6 +106,9 @@ func (c *LiveClient) getAttestations(url string, limit int) ([]*Attestation, err // ref: https://github.com/cli/go-gh/blob/d32c104a9a25c9de3d7c7b07a43ae0091441c858/example_gh_test.go#L96 url = fmt.Sprintf("%s?per_page=%d", url, perPage) + if predicateType != "" { + url = fmt.Sprintf("%s&predicate_type=%s", url, predicateType) + } var attestations []*Attestation var resp AttestationsResponse diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go index 787408a4e..2a62d5662 100644 --- a/pkg/cmd/attestation/api/client_test.go +++ b/pkg/cmd/attestation/api/client_test.go @@ -44,14 +44,14 @@ func NewClientWithMockGHClient(hasNextPage bool) Client { func TestGetByDigest(t *testing.T) { c := NewClientWithMockGHClient(false) - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit) + attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) require.NoError(t, err) require.Equal(t, 5, len(attestations)) bundle := (attestations)[0].Bundle require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit) + attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) require.NoError(t, err) require.Equal(t, 5, len(attestations)) @@ -64,14 +64,14 @@ func TestGetByDigestGreaterThanLimit(t *testing.T) { limit := 3 // The method should return five results when the limit is not set - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, limit) + attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", limit) require.NoError(t, err) require.Equal(t, 3, len(attestations)) bundle := (attestations)[0].Bundle require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, limit) + attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", limit) require.NoError(t, err) require.Equal(t, len(attestations), limit) @@ -81,14 +81,14 @@ func TestGetByDigestGreaterThanLimit(t *testing.T) { func TestGetByDigestWithNextPage(t *testing.T) { c := NewClientWithMockGHClient(true) - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit) + attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) require.NoError(t, err) require.Equal(t, len(attestations), 10) bundle := (attestations)[0].Bundle require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit) + attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) require.NoError(t, err) require.Equal(t, len(attestations), 10) @@ -101,14 +101,14 @@ func TestGetByDigestGreaterThanLimitWithNextPage(t *testing.T) { limit := 7 // The method should return five results when the limit is not set - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, limit) + attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", limit) require.NoError(t, err) require.Equal(t, len(attestations), limit) bundle := (attestations)[0].Bundle require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, limit) + attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", limit) require.NoError(t, err) require.Equal(t, len(attestations), limit) @@ -130,12 +130,12 @@ func TestGetByDigest_NoAttestationsFound(t *testing.T) { logger: io.NewTestHandler(), } - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit) + attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) require.Error(t, err) require.IsType(t, ErrNoAttestationsFound, err) require.Nil(t, attestations) - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit) + attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) require.Error(t, err) require.IsType(t, ErrNoAttestationsFound, err) require.Nil(t, attestations) @@ -153,11 +153,11 @@ func TestGetByDigest_Error(t *testing.T) { logger: io.NewTestHandler(), } - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit) + attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) require.Error(t, err) require.Nil(t, attestations) - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit) + attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) require.Error(t, err) require.Nil(t, attestations) } @@ -362,7 +362,7 @@ func TestGetAttestationsRetries(t *testing.T) { logger: io.NewTestHandler(), } - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit) + attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) require.NoError(t, err) // assert the error path was executed; because this is a paged @@ -375,7 +375,7 @@ func TestGetAttestationsRetries(t *testing.T) { require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") // same test as above, but for GetByOwnerAndDigest: - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit) + attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) require.NoError(t, err) // because we haven't reset the mock, we have added 2 more failed requests @@ -401,7 +401,7 @@ func TestGetAttestationsMaxRetries(t *testing.T) { logger: io.NewTestHandler(), } - _, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit) + _, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) require.Error(t, err) fetcher.AssertNumberOfCalls(t, "OnREST500Error", 4) diff --git a/pkg/cmd/attestation/api/mock_client.go b/pkg/cmd/attestation/api/mock_client.go index b2fd334c0..8e6dcdd6f 100644 --- a/pkg/cmd/attestation/api/mock_client.go +++ b/pkg/cmd/attestation/api/mock_client.go @@ -7,17 +7,17 @@ import ( ) type MockClient struct { - OnGetByRepoAndDigest func(repo, digest string, limit int) ([]*Attestation, error) - OnGetByOwnerAndDigest func(owner, digest string, limit int) ([]*Attestation, error) + OnGetByRepoAndDigest func(repo, digest, predicateType string, limit int) ([]*Attestation, error) + OnGetByOwnerAndDigest func(owner, digest, predicateType string, limit int) ([]*Attestation, error) OnGetTrustDomain func() (string, error) } -func (m MockClient) GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error) { - return m.OnGetByRepoAndDigest(repo, digest, limit) +func (m MockClient) GetByRepoAndDigest(repo, digest, predicateType string, limit int) ([]*Attestation, error) { + return m.OnGetByRepoAndDigest(repo, digest, predicateType, limit) } -func (m MockClient) GetByOwnerAndDigest(owner, digest string, limit int) ([]*Attestation, error) { - return m.OnGetByOwnerAndDigest(owner, digest, limit) +func (m MockClient) GetByOwnerAndDigest(owner, digest, predicateType string, limit int) ([]*Attestation, error) { + return m.OnGetByOwnerAndDigest(owner, digest, predicateType, limit) } func (m MockClient) GetTrustDomain() (string, error) { @@ -28,23 +28,23 @@ func makeTestAttestation() Attestation { return Attestation{Bundle: data.SigstoreBundle(nil), BundleURL: "https://example.com"} } -func OnGetByRepoAndDigestSuccess(repo, digest string, limit int) ([]*Attestation, error) { +func OnGetByRepoAndDigestSuccess(repo, digest, predicateType string, limit int) ([]*Attestation, error) { att1 := makeTestAttestation() att2 := makeTestAttestation() return []*Attestation{&att1, &att2}, nil } -func OnGetByRepoAndDigestFailure(repo, digest string, limit int) ([]*Attestation, error) { +func OnGetByRepoAndDigestFailure(repo, digest, predicateType string, limit int) ([]*Attestation, error) { return nil, fmt.Errorf("failed to fetch by repo and digest") } -func OnGetByOwnerAndDigestSuccess(owner, digest string, limit int) ([]*Attestation, error) { +func OnGetByOwnerAndDigestSuccess(owner, digest, predicateType string, limit int) ([]*Attestation, error) { att1 := makeTestAttestation() att2 := makeTestAttestation() return []*Attestation{&att1, &att2}, nil } -func OnGetByOwnerAndDigestFailure(owner, digest string, limit int) ([]*Attestation, error) { +func OnGetByOwnerAndDigestFailure(owner, digest, predicateType string, limit int) ([]*Attestation, error) { return nil, fmt.Errorf("failed to fetch by owner and digest") } From faef81f4bc7bea7fdee721a700dd563d6669f31f Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Mon, 24 Mar 2025 17:28:50 -0600 Subject: [PATCH 002/249] reorganize getAttestations func to check for remote gh api fetching first Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/download/download_test.go | 4 +- .../attestation/verification/attestation.go | 13 ++++--- pkg/cmd/attestation/verify/attestation.go | 39 ++++++++++--------- pkg/cmd/attestation/verify/options.go | 6 +++ 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/pkg/cmd/attestation/download/download_test.go b/pkg/cmd/attestation/download/download_test.go index ddcd08c92..899d15339 100644 --- a/pkg/cmd/attestation/download/download_test.go +++ b/pkg/cmd/attestation/download/download_test.go @@ -275,7 +275,7 @@ func TestRunDownload(t *testing.T) { t.Run("no attestations found", func(t *testing.T) { opts := baseOpts opts.APIClient = api.MockClient{ - OnGetByOwnerAndDigest: func(repo, digest string, limit int) ([]*api.Attestation, error) { + OnGetByOwnerAndDigest: func(repo, digest, predicateType string, limit int) ([]*api.Attestation, error) { return nil, api.ErrNoAttestationsFound }, } @@ -291,7 +291,7 @@ func TestRunDownload(t *testing.T) { t.Run("failed to fetch attestations", func(t *testing.T) { opts := baseOpts opts.APIClient = api.MockClient{ - OnGetByOwnerAndDigest: func(repo, digest string, limit int) ([]*api.Attestation, error) { + OnGetByOwnerAndDigest: func(repo, digest, predicateType string, limit int) ([]*api.Attestation, error) { return nil, fmt.Errorf("failed to fetch attestations") }, } diff --git a/pkg/cmd/attestation/verification/attestation.go b/pkg/cmd/attestation/verification/attestation.go index db419ebac..53a53722b 100644 --- a/pkg/cmd/attestation/verification/attestation.go +++ b/pkg/cmd/attestation/verification/attestation.go @@ -21,10 +21,11 @@ var ErrUnrecognisedBundleExtension = errors.New("bundle file extension not suppo var ErrEmptyBundleFile = errors.New("provided bundle file is empty") type FetchRemoteAttestationsParams struct { - Digest string - Limit int - Owner string - Repo string + Digest string + Limit int + Owner string + PredicateType string + Repo string } // GetLocalAttestations returns a slice of attestations read from a local bundle file. @@ -96,13 +97,13 @@ func GetRemoteAttestations(client api.Client, params FetchRemoteAttestationsPara // check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo. // If Repo is not set, the field will remain empty. It will not be populated using the value of Owner. if params.Repo != "" { - attestations, err := client.GetByRepoAndDigest(params.Repo, params.Digest, params.Limit) + attestations, err := client.GetByRepoAndDigest(params.Repo, params.Digest, params.PredicateType, params.Limit) if err != nil { return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Repo, err) } return attestations, nil } else if params.Owner != "" { - attestations, err := client.GetByOwnerAndDigest(params.Owner, params.Digest, params.Limit) + attestations, err := client.GetByOwnerAndDigest(params.Owner, params.Digest, params.PredicateType, params.Limit) if err != nil { return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Owner, err) } diff --git a/pkg/cmd/attestation/verify/attestation.go b/pkg/cmd/attestation/verify/attestation.go index bb96c9526..55fcf1f7e 100644 --- a/pkg/cmd/attestation/verify/attestation.go +++ b/pkg/cmd/attestation/verify/attestation.go @@ -10,7 +10,24 @@ import ( ) func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestation, string, error) { - if o.BundlePath != "" { + if o.FetchAttestationsFromGitHubAPI() { + params := verification.FetchRemoteAttestationsParams{ + Digest: a.DigestWithAlg(), + Limit: o.Limit, + Owner: o.Owner, + PredicateType: o.PredicateType, + Repo: o.Repo, + } + + attestations, err := verification.GetRemoteAttestations(o.APIClient, params) + if err != nil { + msg := "✗ Loading attestations from GitHub API failed" + return nil, msg, err + } + pluralAttestation := text.Pluralize(len(attestations), "attestation") + msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation) + return attestations, msg, nil + } else if o.BundlePath != "" { attestations, err := verification.GetLocalAttestations(o.BundlePath) if err != nil { msg := fmt.Sprintf("✗ Loading attestations from %s failed", a.URL) @@ -19,9 +36,7 @@ func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestatio pluralAttestation := text.Pluralize(len(attestations), "attestation") msg := fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.BundlePath) return attestations, msg, nil - } - - if o.UseBundleFromRegistry { + } else if o.UseBundleFromRegistry { attestations, err := verification.GetOCIAttestations(o.OCIClient, a) if err != nil { msg := "✗ Loading attestations from OCI registry failed" @@ -32,21 +47,7 @@ func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestatio return attestations, msg, nil } - params := verification.FetchRemoteAttestationsParams{ - Digest: a.DigestWithAlg(), - Limit: o.Limit, - Owner: o.Owner, - Repo: o.Repo, - } - - attestations, err := verification.GetRemoteAttestations(o.APIClient, params) - if err != nil { - msg := "✗ Loading attestations from GitHub API failed" - return nil, msg, err - } - pluralAttestation := text.Pluralize(len(attestations), "attestation") - msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation) - return attestations, msg, nil + return nil, "", fmt.Errorf("no valid attestation source provided") } func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { diff --git a/pkg/cmd/attestation/verify/options.go b/pkg/cmd/attestation/verify/options.go index 0fbbec55a..e47c4f4a8 100644 --- a/pkg/cmd/attestation/verify/options.go +++ b/pkg/cmd/attestation/verify/options.go @@ -53,6 +53,12 @@ func (opts *Options) Clean() { } } +// FetchAttestationsFromGitHubAPI returns true if the command should fetch attestations from the GitHub API +// It checks that a bundle path is not provided and that the "use bundle from registry" flag is not set +func (opts *Options) FetchAttestationsFromGitHubAPI() bool { + return opts.BundlePath == "" && !opts.UseBundleFromRegistry +} + // AreFlagsValid checks that the provided flag combination is valid // and returns an error otherwise func (opts *Options) AreFlagsValid() error { From ad20ef35d9f97f30c352cf613ba167a08918a4ad Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Mon, 24 Mar 2025 17:35:52 -0600 Subject: [PATCH 003/249] move local and oci registry attestation filtering Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verify/attestation.go | 30 ++++++++++++++++++++--- pkg/cmd/attestation/verify/verify.go | 8 ------ 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/attestation/verify/attestation.go b/pkg/cmd/attestation/verify/attestation.go index 55fcf1f7e..5774170d9 100644 --- a/pkg/cmd/attestation/verify/attestation.go +++ b/pkg/cmd/attestation/verify/attestation.go @@ -9,6 +9,16 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/verification" ) +func filterByPredicateType(predicateType string, attestations []*api.Attestation) ([]*api.Attestation, string, error) { + // Apply predicate type filter to returned attestations + filteredAttestations := verification.FilterAttestations(predicateType, attestations) + if len(filteredAttestations) == 0 { + msg := fmt.Sprintf("✗ No attestations found with predicate type: %s\n", predicateType) + return nil, msg, fmt.Errorf("no matching predicate found") + } + return filteredAttestations, "", nil +} + func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestation, string, error) { if o.FetchAttestationsFromGitHubAPI() { params := verification.FetchRemoteAttestationsParams{ @@ -33,18 +43,30 @@ func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestatio msg := fmt.Sprintf("✗ Loading attestations from %s failed", a.URL) return nil, msg, err } - pluralAttestation := text.Pluralize(len(attestations), "attestation") + + filtered, errMsg, err := filterByPredicateType(o.PredicateType, attestations) + if err != nil { + return nil, errMsg, err + } + + pluralAttestation := text.Pluralize(len(filtered), "attestation") msg := fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.BundlePath) - return attestations, msg, nil + return filtered, msg, nil } else if o.UseBundleFromRegistry { attestations, err := verification.GetOCIAttestations(o.OCIClient, a) if err != nil { msg := "✗ Loading attestations from OCI registry failed" return nil, msg, err } - pluralAttestation := text.Pluralize(len(attestations), "attestation") + + filtered, errMsg, err := filterByPredicateType(o.PredicateType, attestations) + if err != nil { + return nil, errMsg, err + } + + pluralAttestation := text.Pluralize(len(filtered), "attestation") msg := fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.ArtifactPath) - return attestations, msg, nil + return filtered, msg, nil } return nil, "", fmt.Errorf("no valid attestation source provided") diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index 65ae8ca3e..1de4172b4 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -235,14 +235,6 @@ func runVerify(opts *Options) error { // Print the message signifying success fetching attestations opts.Logger.Println(logMsg) - // Apply predicate type filter to returned attestations - filteredAttestations := verification.FilterAttestations(ec.PredicateType, attestations) - if len(filteredAttestations) == 0 { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found with predicate type: %s\n"), opts.PredicateType) - return fmt.Errorf("no matching predicate found") - } - attestations = filteredAttestations - // print information about the policy that will be enforced against attestations opts.Logger.Println("\nThe following policy criteria will be enforced:") opts.Logger.Println(ec.BuildPolicyInformation()) From 95a61974bf4bff0c916fd0e5434b78c614780372 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Mon, 24 Mar 2025 18:01:57 -0600 Subject: [PATCH 004/249] pass params object to api client methods Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/api/client.go | 46 +++++++++------- pkg/cmd/attestation/api/client_test.go | 55 ++++++++++++++----- pkg/cmd/attestation/api/mock_client.go | 20 +++---- pkg/cmd/attestation/download/download.go | 2 +- pkg/cmd/attestation/download/download_test.go | 4 +- .../attestation/verification/attestation.go | 14 +---- pkg/cmd/attestation/verify/attestation.go | 2 +- 7 files changed, 84 insertions(+), 59 deletions(-) diff --git a/pkg/cmd/attestation/api/client.go b/pkg/cmd/attestation/api/client.go index 8c1af495f..ae33d9ce3 100644 --- a/pkg/cmd/attestation/api/client.go +++ b/pkg/cmd/attestation/api/client.go @@ -27,6 +27,15 @@ const ( // Allow injecting backoff interval in tests. var getAttestationRetryInterval = time.Millisecond * 200 +// FetchParams are the parameters for fetching attestations from the GitHub API +type FetchParams struct { + Digest string + Limit int + Owner string + PredicateType string + Repo string +} + // githubApiClient makes REST calls to the GitHub API type githubApiClient interface { REST(hostname, method, p string, body io.Reader, data interface{}) error @@ -39,8 +48,8 @@ type httpClient interface { } type Client interface { - GetByRepoAndDigest(repo, digest, predicateType string, limit int) ([]*Attestation, error) - GetByOwnerAndDigest(owner, digest, predicateType string, limit int) ([]*Attestation, error) + GetByRepoAndDigest(params FetchParams) ([]*Attestation, error) + GetByOwnerAndDigest(params FetchParams) ([]*Attestation, error) GetTrustDomain() (string, error) } @@ -61,21 +70,20 @@ func NewLiveClient(hc *http.Client, host string, l *ioconfig.Handler) *LiveClien } // GetByRepoAndDigest fetches the attestation by repo and digest -func (c *LiveClient) GetByRepoAndDigest(repo, digest, predicateType string, limit int) ([]*Attestation, error) { - c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", digest) - url := fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, repo, digest) - return c.getByURL(url, predicateType, limit) +func (c *LiveClient) GetByRepoAndDigest(params FetchParams) ([]*Attestation, error) { + url := fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, params.Repo, params.Digest) + return c.getByURL(url, params) } // GetByOwnerAndDigest fetches attestation by owner and digest -func (c *LiveClient) GetByOwnerAndDigest(owner, digest, predicateType string, limit int) ([]*Attestation, error) { - c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", digest) - url := fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, owner, digest) - return c.getByURL(url, predicateType, limit) +func (c *LiveClient) GetByOwnerAndDigest(params FetchParams) ([]*Attestation, error) { + url := fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, params.Owner, params.Digest) + return c.getByURL(url, params) } -func (c *LiveClient) getByURL(url, predicateType string, limit int) ([]*Attestation, error) { - attestations, err := c.getAttestations(url, predicateType, limit) +func (c *LiveClient) getByURL(url string, params FetchParams) ([]*Attestation, error) { + c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", params.Digest) + attestations, err := c.getAttestations(url, params) if err != nil { return nil, err } @@ -94,8 +102,8 @@ func (c *LiveClient) GetTrustDomain() (string, error) { return c.getTrustDomain(MetaPath) } -func (c *LiveClient) getAttestations(url, predicateType string, limit int) ([]*Attestation, error) { - perPage := limit +func (c *LiveClient) getAttestations(url string, params FetchParams) ([]*Attestation, error) { + perPage := params.Limit if perPage <= 0 || perPage > maxLimitForFlag { return nil, fmt.Errorf("limit must be greater than 0 and less than or equal to %d", maxLimitForFlag) } @@ -106,8 +114,8 @@ func (c *LiveClient) getAttestations(url, predicateType string, limit int) ([]*A // ref: https://github.com/cli/go-gh/blob/d32c104a9a25c9de3d7c7b07a43ae0091441c858/example_gh_test.go#L96 url = fmt.Sprintf("%s?per_page=%d", url, perPage) - if predicateType != "" { - url = fmt.Sprintf("%s&predicate_type=%s", url, predicateType) + if params.PredicateType != "" { + url = fmt.Sprintf("%s&predicate_type=%s", url, params.PredicateType) } var attestations []*Attestation @@ -115,7 +123,7 @@ func (c *LiveClient) getAttestations(url, predicateType string, limit int) ([]*A bo := backoff.NewConstantBackOff(getAttestationRetryInterval) // if no attestation or less than limit, then keep fetching - for url != "" && len(attestations) < limit { + for url != "" && len(attestations) < params.Limit { err := backoff.Retry(func() error { newURL, restErr := c.githubAPI.RESTWithNext(c.host, http.MethodGet, url, nil, &resp) @@ -143,8 +151,8 @@ func (c *LiveClient) getAttestations(url, predicateType string, limit int) ([]*A return nil, ErrNoAttestationsFound } - if len(attestations) > limit { - return attestations[:limit], nil + if len(attestations) > params.Limit { + return attestations[:params.Limit], nil } return attestations, nil diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go index 2a62d5662..f77b39d08 100644 --- a/pkg/cmd/attestation/api/client_test.go +++ b/pkg/cmd/attestation/api/client_test.go @@ -42,16 +42,24 @@ func NewClientWithMockGHClient(hasNextPage bool) Client { } } +var testFetchParams = FetchParams{ + Digest: testDigest, + Limit: DefaultLimit, + PredicateType: "https://slsa.dev/provenance/v1", +} + func TestGetByDigest(t *testing.T) { c := NewClientWithMockGHClient(false) - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) + testFetchParams.Repo = testRepo + attestations, err := c.GetByRepoAndDigest(testFetchParams) require.NoError(t, err) require.Equal(t, 5, len(attestations)) bundle := (attestations)[0].Bundle require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) + testFetchParams.Owner = testOwner + attestations, err = c.GetByOwnerAndDigest(testFetchParams) require.NoError(t, err) require.Equal(t, 5, len(attestations)) @@ -64,14 +72,17 @@ func TestGetByDigestGreaterThanLimit(t *testing.T) { limit := 3 // The method should return five results when the limit is not set - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", limit) + testFetchParams.Limit = limit + testFetchParams.Repo = testRepo + attestations, err := c.GetByRepoAndDigest(testFetchParams) require.NoError(t, err) require.Equal(t, 3, len(attestations)) bundle := (attestations)[0].Bundle require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", limit) + testFetchParams.Owner = testOwner + attestations, err = c.GetByOwnerAndDigest(testFetchParams) require.NoError(t, err) require.Equal(t, len(attestations), limit) @@ -81,14 +92,17 @@ func TestGetByDigestGreaterThanLimit(t *testing.T) { func TestGetByDigestWithNextPage(t *testing.T) { c := NewClientWithMockGHClient(true) - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) + testFetchParams.Repo = testRepo + testFetchParams.Limit = 30 + attestations, err := c.GetByRepoAndDigest(testFetchParams) require.NoError(t, err) require.Equal(t, len(attestations), 10) bundle := (attestations)[0].Bundle require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) + testFetchParams.Owner = testOwner + attestations, err = c.GetByOwnerAndDigest(testFetchParams) require.NoError(t, err) require.Equal(t, len(attestations), 10) @@ -101,14 +115,17 @@ func TestGetByDigestGreaterThanLimitWithNextPage(t *testing.T) { limit := 7 // The method should return five results when the limit is not set - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", limit) + testFetchParams.Limit = limit + testFetchParams.Repo = testRepo + attestations, err := c.GetByRepoAndDigest(testFetchParams) require.NoError(t, err) require.Equal(t, len(attestations), limit) bundle := (attestations)[0].Bundle require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", limit) + testFetchParams.Owner = testOwner + attestations, err = c.GetByOwnerAndDigest(testFetchParams) require.NoError(t, err) require.Equal(t, len(attestations), limit) @@ -130,12 +147,14 @@ func TestGetByDigest_NoAttestationsFound(t *testing.T) { logger: io.NewTestHandler(), } - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) + testFetchParams.Repo = testRepo + attestations, err := c.GetByRepoAndDigest(testFetchParams) require.Error(t, err) require.IsType(t, ErrNoAttestationsFound, err) require.Nil(t, attestations) - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) + testFetchParams.Owner = testOwner + attestations, err = c.GetByOwnerAndDigest(testFetchParams) require.Error(t, err) require.IsType(t, ErrNoAttestationsFound, err) require.Nil(t, attestations) @@ -153,11 +172,13 @@ func TestGetByDigest_Error(t *testing.T) { logger: io.NewTestHandler(), } - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) + testFetchParams.Repo = testRepo + attestations, err := c.GetByRepoAndDigest(testFetchParams) require.Error(t, err) require.Nil(t, attestations) - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) + testFetchParams.Owner = testOwner + attestations, err = c.GetByOwnerAndDigest(testFetchParams) require.Error(t, err) require.Nil(t, attestations) } @@ -362,7 +383,9 @@ func TestGetAttestationsRetries(t *testing.T) { logger: io.NewTestHandler(), } - attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) + testFetchParams.Repo = testRepo + testFetchParams.Limit = 30 + attestations, err := c.GetByRepoAndDigest(testFetchParams) require.NoError(t, err) // assert the error path was executed; because this is a paged @@ -375,7 +398,8 @@ func TestGetAttestationsRetries(t *testing.T) { require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") // same test as above, but for GetByOwnerAndDigest: - attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) + testFetchParams.Owner = testOwner + attestations, err = c.GetByOwnerAndDigest(testFetchParams) require.NoError(t, err) // because we haven't reset the mock, we have added 2 more failed requests @@ -401,7 +425,8 @@ func TestGetAttestationsMaxRetries(t *testing.T) { logger: io.NewTestHandler(), } - _, err := c.GetByRepoAndDigest(testRepo, testDigest, "https://slsa.dev/provenance/v1", DefaultLimit) + testFetchParams.Repo = testRepo + _, err := c.GetByRepoAndDigest(testFetchParams) require.Error(t, err) fetcher.AssertNumberOfCalls(t, "OnREST500Error", 4) diff --git a/pkg/cmd/attestation/api/mock_client.go b/pkg/cmd/attestation/api/mock_client.go index 8e6dcdd6f..be2b9b76d 100644 --- a/pkg/cmd/attestation/api/mock_client.go +++ b/pkg/cmd/attestation/api/mock_client.go @@ -7,17 +7,17 @@ import ( ) type MockClient struct { - OnGetByRepoAndDigest func(repo, digest, predicateType string, limit int) ([]*Attestation, error) - OnGetByOwnerAndDigest func(owner, digest, predicateType string, limit int) ([]*Attestation, error) + OnGetByRepoAndDigest func(params FetchParams) ([]*Attestation, error) + OnGetByOwnerAndDigest func(params FetchParams) ([]*Attestation, error) OnGetTrustDomain func() (string, error) } -func (m MockClient) GetByRepoAndDigest(repo, digest, predicateType string, limit int) ([]*Attestation, error) { - return m.OnGetByRepoAndDigest(repo, digest, predicateType, limit) +func (m MockClient) GetByRepoAndDigest(params FetchParams) ([]*Attestation, error) { + return m.OnGetByRepoAndDigest(params) } -func (m MockClient) GetByOwnerAndDigest(owner, digest, predicateType string, limit int) ([]*Attestation, error) { - return m.OnGetByOwnerAndDigest(owner, digest, predicateType, limit) +func (m MockClient) GetByOwnerAndDigest(params FetchParams) ([]*Attestation, error) { + return m.OnGetByOwnerAndDigest(params) } func (m MockClient) GetTrustDomain() (string, error) { @@ -28,23 +28,23 @@ func makeTestAttestation() Attestation { return Attestation{Bundle: data.SigstoreBundle(nil), BundleURL: "https://example.com"} } -func OnGetByRepoAndDigestSuccess(repo, digest, predicateType string, limit int) ([]*Attestation, error) { +func OnGetByRepoAndDigestSuccess(params FetchParams) ([]*Attestation, error) { att1 := makeTestAttestation() att2 := makeTestAttestation() return []*Attestation{&att1, &att2}, nil } -func OnGetByRepoAndDigestFailure(repo, digest, predicateType string, limit int) ([]*Attestation, error) { +func OnGetByRepoAndDigestFailure(params FetchParams) ([]*Attestation, error) { return nil, fmt.Errorf("failed to fetch by repo and digest") } -func OnGetByOwnerAndDigestSuccess(owner, digest, predicateType string, limit int) ([]*Attestation, error) { +func OnGetByOwnerAndDigestSuccess(params FetchParams) ([]*Attestation, error) { att1 := makeTestAttestation() att2 := makeTestAttestation() return []*Attestation{&att1, &att2}, nil } -func OnGetByOwnerAndDigestFailure(owner, digest, predicateType string, limit int) ([]*Attestation, error) { +func OnGetByOwnerAndDigestFailure(params FetchParams) ([]*Attestation, error) { return nil, fmt.Errorf("failed to fetch by owner and digest") } diff --git a/pkg/cmd/attestation/download/download.go b/pkg/cmd/attestation/download/download.go index 6913c0787..65b6f83df 100644 --- a/pkg/cmd/attestation/download/download.go +++ b/pkg/cmd/attestation/download/download.go @@ -127,7 +127,7 @@ func runDownload(opts *Options) error { opts.Logger.VerbosePrintf("Downloading trusted metadata for artifact %s\n\n", opts.ArtifactPath) - params := verification.FetchRemoteAttestationsParams{ + params := api.FetchParams{ Digest: artifact.DigestWithAlg(), Limit: opts.Limit, Owner: opts.Owner, diff --git a/pkg/cmd/attestation/download/download_test.go b/pkg/cmd/attestation/download/download_test.go index 899d15339..629de4a66 100644 --- a/pkg/cmd/attestation/download/download_test.go +++ b/pkg/cmd/attestation/download/download_test.go @@ -275,7 +275,7 @@ func TestRunDownload(t *testing.T) { t.Run("no attestations found", func(t *testing.T) { opts := baseOpts opts.APIClient = api.MockClient{ - OnGetByOwnerAndDigest: func(repo, digest, predicateType string, limit int) ([]*api.Attestation, error) { + OnGetByOwnerAndDigest: func(params api.FetchParams) ([]*api.Attestation, error) { return nil, api.ErrNoAttestationsFound }, } @@ -291,7 +291,7 @@ func TestRunDownload(t *testing.T) { t.Run("failed to fetch attestations", func(t *testing.T) { opts := baseOpts opts.APIClient = api.MockClient{ - OnGetByOwnerAndDigest: func(repo, digest, predicateType string, limit int) ([]*api.Attestation, error) { + OnGetByOwnerAndDigest: func(params api.FetchParams) ([]*api.Attestation, error) { return nil, fmt.Errorf("failed to fetch attestations") }, } diff --git a/pkg/cmd/attestation/verification/attestation.go b/pkg/cmd/attestation/verification/attestation.go index 53a53722b..c4d5330f6 100644 --- a/pkg/cmd/attestation/verification/attestation.go +++ b/pkg/cmd/attestation/verification/attestation.go @@ -20,14 +20,6 @@ const SLSAPredicateV1 = "https://slsa.dev/provenance/v1" var ErrUnrecognisedBundleExtension = errors.New("bundle file extension not supported, must be json or jsonl") var ErrEmptyBundleFile = errors.New("provided bundle file is empty") -type FetchRemoteAttestationsParams struct { - Digest string - Limit int - Owner string - PredicateType string - Repo string -} - // GetLocalAttestations returns a slice of attestations read from a local bundle file. func GetLocalAttestations(path string) ([]*api.Attestation, error) { var attestations []*api.Attestation @@ -90,20 +82,20 @@ func loadBundlesFromJSONLinesFile(path string) ([]*api.Attestation, error) { return attestations, nil } -func GetRemoteAttestations(client api.Client, params FetchRemoteAttestationsParams) ([]*api.Attestation, error) { +func GetRemoteAttestations(client api.Client, params api.FetchParams) ([]*api.Attestation, error) { if client == nil { return nil, fmt.Errorf("api client must be provided") } // check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo. // If Repo is not set, the field will remain empty. It will not be populated using the value of Owner. if params.Repo != "" { - attestations, err := client.GetByRepoAndDigest(params.Repo, params.Digest, params.PredicateType, params.Limit) + attestations, err := client.GetByRepoAndDigest(params) if err != nil { return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Repo, err) } return attestations, nil } else if params.Owner != "" { - attestations, err := client.GetByOwnerAndDigest(params.Owner, params.Digest, params.PredicateType, params.Limit) + attestations, err := client.GetByOwnerAndDigest(params) if err != nil { return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Owner, err) } diff --git a/pkg/cmd/attestation/verify/attestation.go b/pkg/cmd/attestation/verify/attestation.go index 5774170d9..e8211003c 100644 --- a/pkg/cmd/attestation/verify/attestation.go +++ b/pkg/cmd/attestation/verify/attestation.go @@ -21,7 +21,7 @@ func filterByPredicateType(predicateType string, attestations []*api.Attestation func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestation, string, error) { if o.FetchAttestationsFromGitHubAPI() { - params := verification.FetchRemoteAttestationsParams{ + params := api.FetchParams{ Digest: a.DigestWithAlg(), Limit: o.Limit, Owner: o.Owner, From 5a895b9d72377b7793a1b0084c3ecb6261b15c6e Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Mon, 24 Mar 2025 18:12:41 -0600 Subject: [PATCH 005/249] dedpulicate if else logic Signed-off-by: Meredith Lancaster --- .../attestation/verification/attestation.go | 25 ++++---- pkg/cmd/attestation/verify/attestation.go | 58 +++++++++---------- 2 files changed, 41 insertions(+), 42 deletions(-) diff --git a/pkg/cmd/attestation/verification/attestation.go b/pkg/cmd/attestation/verification/attestation.go index c4d5330f6..33d8b18b8 100644 --- a/pkg/cmd/attestation/verification/attestation.go +++ b/pkg/cmd/attestation/verification/attestation.go @@ -88,20 +88,23 @@ func GetRemoteAttestations(client api.Client, params api.FetchParams) ([]*api.At } // check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo. // If Repo is not set, the field will remain empty. It will not be populated using the value of Owner. + var attestations []*api.Attestation + var err error + var owner string if params.Repo != "" { - attestations, err := client.GetByRepoAndDigest(params) - if err != nil { - return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Repo, err) - } - return attestations, nil + attestations, err = client.GetByRepoAndDigest(params) + owner = params.Repo } else if params.Owner != "" { - attestations, err := client.GetByOwnerAndDigest(params) - if err != nil { - return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Owner, err) - } - return attestations, nil + attestations, err = client.GetByOwnerAndDigest(params) + owner = params.Owner + } else { + return nil, fmt.Errorf("owner or repo must be provided") } - return nil, fmt.Errorf("owner or repo must be provided") + + if err != nil { + return nil, fmt.Errorf("failed to fetch attestations from %s: %w", owner, err) + } + return attestations, err } func GetOCIAttestations(client oci.Client, artifact artifact.DigestedArtifact) ([]*api.Attestation, error) { diff --git a/pkg/cmd/attestation/verify/attestation.go b/pkg/cmd/attestation/verify/attestation.go index e8211003c..f956e82b8 100644 --- a/pkg/cmd/attestation/verify/attestation.go +++ b/pkg/cmd/attestation/verify/attestation.go @@ -37,39 +37,35 @@ func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestatio pluralAttestation := text.Pluralize(len(attestations), "attestation") msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation) return attestations, msg, nil - } else if o.BundlePath != "" { - attestations, err := verification.GetLocalAttestations(o.BundlePath) - if err != nil { - msg := fmt.Sprintf("✗ Loading attestations from %s failed", a.URL) - return nil, msg, err - } - - filtered, errMsg, err := filterByPredicateType(o.PredicateType, attestations) - if err != nil { - return nil, errMsg, err - } - - pluralAttestation := text.Pluralize(len(filtered), "attestation") - msg := fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.BundlePath) - return filtered, msg, nil - } else if o.UseBundleFromRegistry { - attestations, err := verification.GetOCIAttestations(o.OCIClient, a) - if err != nil { - msg := "✗ Loading attestations from OCI registry failed" - return nil, msg, err - } - - filtered, errMsg, err := filterByPredicateType(o.PredicateType, attestations) - if err != nil { - return nil, errMsg, err - } - - pluralAttestation := text.Pluralize(len(filtered), "attestation") - msg := fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.ArtifactPath) - return filtered, msg, nil } - return nil, "", fmt.Errorf("no valid attestation source provided") + var attestations []*api.Attestation + var err error + var errMsg string + if o.BundlePath != "" { + attestations, err = verification.GetLocalAttestations(o.BundlePath) + if err != nil { + errMsg = fmt.Sprintf("✗ Loading attestations from %s failed", a.URL) + } + } else if o.UseBundleFromRegistry { + attestations, err = verification.GetOCIAttestations(o.OCIClient, a) + if err != nil { + errMsg = "✗ Loading attestations from OCI registry failed" + } + } + + if err != nil { + return nil, errMsg, err + } + + filtered, errMsg, err := filterByPredicateType(o.PredicateType, attestations) + if err != nil { + return nil, errMsg, err + } + + pluralAttestation := text.Pluralize(len(filtered), "attestation") + msg := fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.BundlePath) + return filtered, msg, nil } func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { From a9cc7b481e2ab718163702cfe57565fe83370e19 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Mon, 24 Mar 2025 18:28:27 -0600 Subject: [PATCH 006/249] create single fetch by digest client method Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/api/client.go | 35 ++++++++++----- pkg/cmd/attestation/api/client_test.go | 32 +++++++------- pkg/cmd/attestation/api/mock_client.go | 44 +++++++------------ pkg/cmd/attestation/download/download.go | 2 +- pkg/cmd/attestation/download/download_test.go | 4 +- .../attestation/verification/attestation.go | 25 ----------- pkg/cmd/attestation/verify/attestation.go | 2 +- 7 files changed, 58 insertions(+), 86 deletions(-) diff --git a/pkg/cmd/attestation/api/client.go b/pkg/cmd/attestation/api/client.go index ae33d9ce3..a3e627852 100644 --- a/pkg/cmd/attestation/api/client.go +++ b/pkg/cmd/attestation/api/client.go @@ -48,8 +48,7 @@ type httpClient interface { } type Client interface { - GetByRepoAndDigest(params FetchParams) ([]*Attestation, error) - GetByOwnerAndDigest(params FetchParams) ([]*Attestation, error) + GetByDigest(params FetchParams) ([]*Attestation, error) GetTrustDomain() (string, error) } @@ -69,16 +68,28 @@ func NewLiveClient(hc *http.Client, host string, l *ioconfig.Handler) *LiveClien } } -// GetByRepoAndDigest fetches the attestation by repo and digest -func (c *LiveClient) GetByRepoAndDigest(params FetchParams) ([]*Attestation, error) { - url := fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, params.Repo, params.Digest) - return c.getByURL(url, params) -} - -// GetByOwnerAndDigest fetches attestation by owner and digest -func (c *LiveClient) GetByOwnerAndDigest(params FetchParams) ([]*Attestation, error) { - url := fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, params.Owner, params.Digest) - return c.getByURL(url, params) +// GetByDigest fetches the attestation by digest and either owner or repo +// depending on which is provided +func (c *LiveClient) GetByDigest(params FetchParams) ([]*Attestation, error) { + if params.Repo == "" && params.Owner == "" { + return nil, fmt.Errorf("owner or repo must be provided") + } else if params.Repo != "" { + // check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo. + // If Repo is not set, the field will remain empty. It will not be populated using the value of Owner. + url := fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, params.Repo, params.Digest) + attestations, err := c.getByURL(url, params) + if err != nil { + return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Repo, err) + } + return attestations, nil + } else { + url := fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, params.Owner, params.Digest) + attestations, err := c.getByURL(url, params) + if err != nil { + return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Owner, err) + } + return attestations, nil + } } func (c *LiveClient) getByURL(url string, params FetchParams) ([]*Attestation, error) { diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go index f77b39d08..fb2e36b4d 100644 --- a/pkg/cmd/attestation/api/client_test.go +++ b/pkg/cmd/attestation/api/client_test.go @@ -51,7 +51,7 @@ var testFetchParams = FetchParams{ func TestGetByDigest(t *testing.T) { c := NewClientWithMockGHClient(false) testFetchParams.Repo = testRepo - attestations, err := c.GetByRepoAndDigest(testFetchParams) + attestations, err := c.GetByDigest(testFetchParams) require.NoError(t, err) require.Equal(t, 5, len(attestations)) @@ -59,7 +59,7 @@ func TestGetByDigest(t *testing.T) { require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") testFetchParams.Owner = testOwner - attestations, err = c.GetByOwnerAndDigest(testFetchParams) + attestations, err = c.GetByDigest(testFetchParams) require.NoError(t, err) require.Equal(t, 5, len(attestations)) @@ -74,7 +74,7 @@ func TestGetByDigestGreaterThanLimit(t *testing.T) { // The method should return five results when the limit is not set testFetchParams.Limit = limit testFetchParams.Repo = testRepo - attestations, err := c.GetByRepoAndDigest(testFetchParams) + attestations, err := c.GetByDigest(testFetchParams) require.NoError(t, err) require.Equal(t, 3, len(attestations)) @@ -82,7 +82,7 @@ func TestGetByDigestGreaterThanLimit(t *testing.T) { require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") testFetchParams.Owner = testOwner - attestations, err = c.GetByOwnerAndDigest(testFetchParams) + attestations, err = c.GetByDigest(testFetchParams) require.NoError(t, err) require.Equal(t, len(attestations), limit) @@ -94,7 +94,7 @@ func TestGetByDigestWithNextPage(t *testing.T) { c := NewClientWithMockGHClient(true) testFetchParams.Repo = testRepo testFetchParams.Limit = 30 - attestations, err := c.GetByRepoAndDigest(testFetchParams) + attestations, err := c.GetByDigest(testFetchParams) require.NoError(t, err) require.Equal(t, len(attestations), 10) @@ -102,7 +102,7 @@ func TestGetByDigestWithNextPage(t *testing.T) { require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") testFetchParams.Owner = testOwner - attestations, err = c.GetByOwnerAndDigest(testFetchParams) + attestations, err = c.GetByDigest(testFetchParams) require.NoError(t, err) require.Equal(t, len(attestations), 10) @@ -117,7 +117,7 @@ func TestGetByDigestGreaterThanLimitWithNextPage(t *testing.T) { // The method should return five results when the limit is not set testFetchParams.Limit = limit testFetchParams.Repo = testRepo - attestations, err := c.GetByRepoAndDigest(testFetchParams) + attestations, err := c.GetByDigest(testFetchParams) require.NoError(t, err) require.Equal(t, len(attestations), limit) @@ -125,7 +125,7 @@ func TestGetByDigestGreaterThanLimitWithNextPage(t *testing.T) { require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") testFetchParams.Owner = testOwner - attestations, err = c.GetByOwnerAndDigest(testFetchParams) + attestations, err = c.GetByDigest(testFetchParams) require.NoError(t, err) require.Equal(t, len(attestations), limit) @@ -148,13 +148,13 @@ func TestGetByDigest_NoAttestationsFound(t *testing.T) { } testFetchParams.Repo = testRepo - attestations, err := c.GetByRepoAndDigest(testFetchParams) + attestations, err := c.GetByDigest(testFetchParams) require.Error(t, err) require.IsType(t, ErrNoAttestationsFound, err) require.Nil(t, attestations) testFetchParams.Owner = testOwner - attestations, err = c.GetByOwnerAndDigest(testFetchParams) + attestations, err = c.GetByDigest(testFetchParams) require.Error(t, err) require.IsType(t, ErrNoAttestationsFound, err) require.Nil(t, attestations) @@ -173,12 +173,12 @@ func TestGetByDigest_Error(t *testing.T) { } testFetchParams.Repo = testRepo - attestations, err := c.GetByRepoAndDigest(testFetchParams) + attestations, err := c.GetByDigest(testFetchParams) require.Error(t, err) require.Nil(t, attestations) testFetchParams.Owner = testOwner - attestations, err = c.GetByOwnerAndDigest(testFetchParams) + attestations, err = c.GetByDigest(testFetchParams) require.Error(t, err) require.Nil(t, attestations) } @@ -385,7 +385,7 @@ func TestGetAttestationsRetries(t *testing.T) { testFetchParams.Repo = testRepo testFetchParams.Limit = 30 - attestations, err := c.GetByRepoAndDigest(testFetchParams) + attestations, err := c.GetByDigest(testFetchParams) require.NoError(t, err) // assert the error path was executed; because this is a paged @@ -397,9 +397,9 @@ func TestGetAttestationsRetries(t *testing.T) { bundle := (attestations)[0].Bundle require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") - // same test as above, but for GetByOwnerAndDigest: + // same test as above, but for GetByDigest: testFetchParams.Owner = testOwner - attestations, err = c.GetByOwnerAndDigest(testFetchParams) + attestations, err = c.GetByDigest(testFetchParams) require.NoError(t, err) // because we haven't reset the mock, we have added 2 more failed requests @@ -426,7 +426,7 @@ func TestGetAttestationsMaxRetries(t *testing.T) { } testFetchParams.Repo = testRepo - _, err := c.GetByRepoAndDigest(testFetchParams) + _, err := c.GetByDigest(testFetchParams) require.Error(t, err) fetcher.AssertNumberOfCalls(t, "OnREST500Error", 4) diff --git a/pkg/cmd/attestation/api/mock_client.go b/pkg/cmd/attestation/api/mock_client.go index be2b9b76d..efcedc8b5 100644 --- a/pkg/cmd/attestation/api/mock_client.go +++ b/pkg/cmd/attestation/api/mock_client.go @@ -6,58 +6,44 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/test/data" ) +func makeTestAttestation() Attestation { + return Attestation{Bundle: data.SigstoreBundle(nil), BundleURL: "https://example.com"} +} + type MockClient struct { - OnGetByRepoAndDigest func(params FetchParams) ([]*Attestation, error) - OnGetByOwnerAndDigest func(params FetchParams) ([]*Attestation, error) - OnGetTrustDomain func() (string, error) + OnGetByDigest func(params FetchParams) ([]*Attestation, error) + OnGetTrustDomain func() (string, error) } -func (m MockClient) GetByRepoAndDigest(params FetchParams) ([]*Attestation, error) { - return m.OnGetByRepoAndDigest(params) -} - -func (m MockClient) GetByOwnerAndDigest(params FetchParams) ([]*Attestation, error) { - return m.OnGetByOwnerAndDigest(params) +func (m MockClient) GetByDigest(params FetchParams) ([]*Attestation, error) { + return m.OnGetByDigest(params) } func (m MockClient) GetTrustDomain() (string, error) { return m.OnGetTrustDomain() } -func makeTestAttestation() Attestation { - return Attestation{Bundle: data.SigstoreBundle(nil), BundleURL: "https://example.com"} -} - -func OnGetByRepoAndDigestSuccess(params FetchParams) ([]*Attestation, error) { +func OnGetByDigestSuccess(params FetchParams) ([]*Attestation, error) { att1 := makeTestAttestation() att2 := makeTestAttestation() return []*Attestation{&att1, &att2}, nil } -func OnGetByRepoAndDigestFailure(params FetchParams) ([]*Attestation, error) { - return nil, fmt.Errorf("failed to fetch by repo and digest") -} - -func OnGetByOwnerAndDigestSuccess(params FetchParams) ([]*Attestation, error) { - att1 := makeTestAttestation() - att2 := makeTestAttestation() - return []*Attestation{&att1, &att2}, nil -} - -func OnGetByOwnerAndDigestFailure(params FetchParams) ([]*Attestation, error) { +func OnGetByDigestFailure(params FetchParams) ([]*Attestation, error) { + if params.Repo != "" { + return nil, fmt.Errorf("failed to fetch by repo and digest") + } return nil, fmt.Errorf("failed to fetch by owner and digest") } func NewTestClient() *MockClient { return &MockClient{ - OnGetByRepoAndDigest: OnGetByRepoAndDigestSuccess, - OnGetByOwnerAndDigest: OnGetByOwnerAndDigestSuccess, + OnGetByDigest: OnGetByDigestSuccess, } } func NewFailTestClient() *MockClient { return &MockClient{ - OnGetByRepoAndDigest: OnGetByRepoAndDigestFailure, - OnGetByOwnerAndDigest: OnGetByOwnerAndDigestFailure, + OnGetByDigest: OnGetByDigestFailure, } } diff --git a/pkg/cmd/attestation/download/download.go b/pkg/cmd/attestation/download/download.go index 65b6f83df..2a297bc9a 100644 --- a/pkg/cmd/attestation/download/download.go +++ b/pkg/cmd/attestation/download/download.go @@ -133,7 +133,7 @@ func runDownload(opts *Options) error { Owner: opts.Owner, Repo: opts.Repo, } - attestations, err := verification.GetRemoteAttestations(opts.APIClient, params) + attestations, err := opts.APIClient.GetByDigest(params) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { fmt.Fprintf(opts.Logger.IO.Out, "No attestations found for %s\n", opts.ArtifactPath) diff --git a/pkg/cmd/attestation/download/download_test.go b/pkg/cmd/attestation/download/download_test.go index 629de4a66..11872daf9 100644 --- a/pkg/cmd/attestation/download/download_test.go +++ b/pkg/cmd/attestation/download/download_test.go @@ -275,7 +275,7 @@ func TestRunDownload(t *testing.T) { t.Run("no attestations found", func(t *testing.T) { opts := baseOpts opts.APIClient = api.MockClient{ - OnGetByOwnerAndDigest: func(params api.FetchParams) ([]*api.Attestation, error) { + OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) { return nil, api.ErrNoAttestationsFound }, } @@ -291,7 +291,7 @@ func TestRunDownload(t *testing.T) { t.Run("failed to fetch attestations", func(t *testing.T) { opts := baseOpts opts.APIClient = api.MockClient{ - OnGetByOwnerAndDigest: func(params api.FetchParams) ([]*api.Attestation, error) { + OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) { return nil, fmt.Errorf("failed to fetch attestations") }, } diff --git a/pkg/cmd/attestation/verification/attestation.go b/pkg/cmd/attestation/verification/attestation.go index 33d8b18b8..9757b72c5 100644 --- a/pkg/cmd/attestation/verification/attestation.go +++ b/pkg/cmd/attestation/verification/attestation.go @@ -82,31 +82,6 @@ func loadBundlesFromJSONLinesFile(path string) ([]*api.Attestation, error) { return attestations, nil } -func GetRemoteAttestations(client api.Client, params api.FetchParams) ([]*api.Attestation, error) { - if client == nil { - return nil, fmt.Errorf("api client must be provided") - } - // check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo. - // If Repo is not set, the field will remain empty. It will not be populated using the value of Owner. - var attestations []*api.Attestation - var err error - var owner string - if params.Repo != "" { - attestations, err = client.GetByRepoAndDigest(params) - owner = params.Repo - } else if params.Owner != "" { - attestations, err = client.GetByOwnerAndDigest(params) - owner = params.Owner - } else { - return nil, fmt.Errorf("owner or repo must be provided") - } - - if err != nil { - return nil, fmt.Errorf("failed to fetch attestations from %s: %w", owner, err) - } - return attestations, err -} - func GetOCIAttestations(client oci.Client, artifact artifact.DigestedArtifact) ([]*api.Attestation, error) { attestations, err := client.GetAttestations(artifact.NameRef(), artifact.DigestWithAlg()) if err != nil { diff --git a/pkg/cmd/attestation/verify/attestation.go b/pkg/cmd/attestation/verify/attestation.go index f956e82b8..f91526bbd 100644 --- a/pkg/cmd/attestation/verify/attestation.go +++ b/pkg/cmd/attestation/verify/attestation.go @@ -29,7 +29,7 @@ func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestatio Repo: o.Repo, } - attestations, err := verification.GetRemoteAttestations(o.APIClient, params) + attestations, err := o.APIClient.GetByDigest(params) if err != nil { msg := "✗ Loading attestations from GitHub API failed" return nil, msg, err From a856a796f0e0ed801155933f4ff7daf07cef0604 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Mon, 24 Mar 2025 18:34:54 -0600 Subject: [PATCH 007/249] remove duplicate predicate filtering code Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/download/download.go | 7 +++---- pkg/cmd/attestation/verification/attestation.go | 8 ++++++-- .../attestation/verification/attestation_test.go | 7 ++++--- pkg/cmd/attestation/verify/attestation.go | 14 ++------------ 4 files changed, 15 insertions(+), 21 deletions(-) diff --git a/pkg/cmd/attestation/download/download.go b/pkg/cmd/attestation/download/download.go index 2a297bc9a..86cf08d72 100644 --- a/pkg/cmd/attestation/download/download.go +++ b/pkg/cmd/attestation/download/download.go @@ -144,10 +144,9 @@ func runDownload(opts *Options) error { // Apply predicate type filter to returned attestations if opts.PredicateType != "" { - filteredAttestations := verification.FilterAttestations(opts.PredicateType, attestations) - - if len(filteredAttestations) == 0 { - return fmt.Errorf("no attestations found with predicate type: %s", opts.PredicateType) + filteredAttestations, err := verification.FilterAttestations(opts.PredicateType, attestations) + if err != nil { + return fmt.Errorf("failed to filter attestations: %v", err) } attestations = filteredAttestations diff --git a/pkg/cmd/attestation/verification/attestation.go b/pkg/cmd/attestation/verification/attestation.go index 9757b72c5..ba357a5cc 100644 --- a/pkg/cmd/attestation/verification/attestation.go +++ b/pkg/cmd/attestation/verification/attestation.go @@ -97,7 +97,7 @@ type IntotoStatement struct { PredicateType string `json:"predicateType"` } -func FilterAttestations(predicateType string, attestations []*api.Attestation) []*api.Attestation { +func FilterAttestations(predicateType string, attestations []*api.Attestation) ([]*api.Attestation, error) { filteredAttestations := []*api.Attestation{} for _, each := range attestations { @@ -118,5 +118,9 @@ func FilterAttestations(predicateType string, attestations []*api.Attestation) [ } } - return filteredAttestations + if len(filteredAttestations) == 0 { + return nil, fmt.Errorf("no attestations found with predicate type: %s", predicateType) + } + + return filteredAttestations, nil } diff --git a/pkg/cmd/attestation/verification/attestation_test.go b/pkg/cmd/attestation/verification/attestation_test.go index 8acff0c37..55a447cf4 100644 --- a/pkg/cmd/attestation/verification/attestation_test.go +++ b/pkg/cmd/attestation/verification/attestation_test.go @@ -157,10 +157,11 @@ func TestFilterAttestations(t *testing.T) { }, } - filtered := FilterAttestations("https://slsa.dev/provenance/v1", attestations) - + filtered, err := FilterAttestations("https://slsa.dev/provenance/v1", attestations) require.Len(t, filtered, 1) + require.NoError(t, err) - filtered = FilterAttestations("NonExistentPredicate", attestations) + filtered, err = FilterAttestations("NonExistentPredicate", attestations) require.Len(t, filtered, 0) + require.NoError(t, err) } diff --git a/pkg/cmd/attestation/verify/attestation.go b/pkg/cmd/attestation/verify/attestation.go index f91526bbd..c09b433b0 100644 --- a/pkg/cmd/attestation/verify/attestation.go +++ b/pkg/cmd/attestation/verify/attestation.go @@ -9,16 +9,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/verification" ) -func filterByPredicateType(predicateType string, attestations []*api.Attestation) ([]*api.Attestation, string, error) { - // Apply predicate type filter to returned attestations - filteredAttestations := verification.FilterAttestations(predicateType, attestations) - if len(filteredAttestations) == 0 { - msg := fmt.Sprintf("✗ No attestations found with predicate type: %s\n", predicateType) - return nil, msg, fmt.Errorf("no matching predicate found") - } - return filteredAttestations, "", nil -} - func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestation, string, error) { if o.FetchAttestationsFromGitHubAPI() { params := api.FetchParams{ @@ -58,9 +48,9 @@ func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestatio return nil, errMsg, err } - filtered, errMsg, err := filterByPredicateType(o.PredicateType, attestations) + filtered, err := verification.FilterAttestations(o.PredicateType, attestations) if err != nil { - return nil, errMsg, err + return nil, err.Error(), err } pluralAttestation := text.Pluralize(len(filtered), "attestation") From 0d0654738b7bf1cc1f200dd086c284d0a25f5be0 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Mon, 24 Mar 2025 18:58:35 -0600 Subject: [PATCH 008/249] simplify client methods Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/api/client.go | 66 +++++++++++++++++-------------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/pkg/cmd/attestation/api/client.go b/pkg/cmd/attestation/api/client.go index a3e627852..d0cffcb27 100644 --- a/pkg/cmd/attestation/api/client.go +++ b/pkg/cmd/attestation/api/client.go @@ -36,6 +36,19 @@ type FetchParams struct { Repo string } +func (p *FetchParams) Validate() error { + if p.Digest == "" { + return fmt.Errorf("digest must be provided") + } + if p.Limit <= 0 || p.Limit > maxLimitForFlag { + return fmt.Errorf("limit must be greater than 0 and less than or equal to %d", maxLimitForFlag) + } + if p.Repo == "" && p.Owner == "" { + return fmt.Errorf("owner or repo must be provided") + } + return nil +} + // githubApiClient makes REST calls to the GitHub API type githubApiClient interface { REST(hostname, method, p string, body io.Reader, data interface{}) error @@ -71,30 +84,8 @@ func NewLiveClient(hc *http.Client, host string, l *ioconfig.Handler) *LiveClien // GetByDigest fetches the attestation by digest and either owner or repo // depending on which is provided func (c *LiveClient) GetByDigest(params FetchParams) ([]*Attestation, error) { - if params.Repo == "" && params.Owner == "" { - return nil, fmt.Errorf("owner or repo must be provided") - } else if params.Repo != "" { - // check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo. - // If Repo is not set, the field will remain empty. It will not be populated using the value of Owner. - url := fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, params.Repo, params.Digest) - attestations, err := c.getByURL(url, params) - if err != nil { - return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Repo, err) - } - return attestations, nil - } else { - url := fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, params.Owner, params.Digest) - attestations, err := c.getByURL(url, params) - if err != nil { - return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Owner, err) - } - return attestations, nil - } -} - -func (c *LiveClient) getByURL(url string, params FetchParams) ([]*Attestation, error) { c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", params.Digest) - attestations, err := c.getAttestations(url, params) + attestations, err := c.getAttestations(params) if err != nil { return nil, err } @@ -107,13 +98,24 @@ func (c *LiveClient) getByURL(url string, params FetchParams) ([]*Attestation, e return bundles, nil } -// GetTrustDomain returns the current trust domain. If the default is used -// the empty string is returned -func (c *LiveClient) GetTrustDomain() (string, error) { - return c.getTrustDomain(MetaPath) -} +func (c *LiveClient) getAttestations(params FetchParams) ([]*Attestation, error) { + if err := params.Validate(); err != nil { + return nil, err + } + + var urlTemplate string + var resourceOwner string + if params.Repo != "" { + // check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo. + // If Repo is not set, the field will remain empty. It will not be populated using the value of Owner. + urlTemplate = GetAttestationByRepoAndSubjectDigestPath + resourceOwner = params.Repo + } else { + urlTemplate = GetAttestationByOwnerAndSubjectDigestPath + resourceOwner = params.Owner + } + url := fmt.Sprintf(urlTemplate, resourceOwner, params.Digest) -func (c *LiveClient) getAttestations(url string, params FetchParams) ([]*Attestation, error) { perPage := params.Limit if perPage <= 0 || perPage > maxLimitForFlag { return nil, fmt.Errorf("limit must be greater than 0 and less than or equal to %d", maxLimitForFlag) @@ -263,6 +265,12 @@ func shouldRetry(err error) bool { return false } +// GetTrustDomain returns the current trust domain. If the default is used +// the empty string is returned +func (c *LiveClient) GetTrustDomain() (string, error) { + return c.getTrustDomain(MetaPath) +} + func (c *LiveClient) getTrustDomain(url string) (string, error) { var resp MetaResponse From baeaf66011464ea49954dcf0ec25574b8fa7070c Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Mon, 24 Mar 2025 19:13:27 -0600 Subject: [PATCH 009/249] restructure api client methods Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/api/client.go | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/pkg/cmd/attestation/api/client.go b/pkg/cmd/attestation/api/client.go index d0cffcb27..61d0bee52 100644 --- a/pkg/cmd/attestation/api/client.go +++ b/pkg/cmd/attestation/api/client.go @@ -98,29 +98,21 @@ func (c *LiveClient) GetByDigest(params FetchParams) ([]*Attestation, error) { return bundles, nil } -func (c *LiveClient) getAttestations(params FetchParams) ([]*Attestation, error) { +func (c *LiveClient) buildRequestURL(params FetchParams) (string, error) { if err := params.Validate(); err != nil { - return nil, err + return "", err } - var urlTemplate string - var resourceOwner string + var url string if params.Repo != "" { // check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo. // If Repo is not set, the field will remain empty. It will not be populated using the value of Owner. - urlTemplate = GetAttestationByRepoAndSubjectDigestPath - resourceOwner = params.Repo + url = fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, params.Repo, params.Digest) } else { - urlTemplate = GetAttestationByOwnerAndSubjectDigestPath - resourceOwner = params.Owner + url = fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, params.Owner, params.Digest) } - url := fmt.Sprintf(urlTemplate, resourceOwner, params.Digest) perPage := params.Limit - if perPage <= 0 || perPage > maxLimitForFlag { - return nil, fmt.Errorf("limit must be greater than 0 and less than or equal to %d", maxLimitForFlag) - } - if perPage > maxLimitForFetch { perPage = maxLimitForFetch } @@ -130,6 +122,14 @@ func (c *LiveClient) getAttestations(params FetchParams) ([]*Attestation, error) if params.PredicateType != "" { url = fmt.Sprintf("%s&predicate_type=%s", url, params.PredicateType) } + return url, nil +} + +func (c *LiveClient) getAttestations(params FetchParams) ([]*Attestation, error) { + url, err := c.buildRequestURL(params) + if err != nil { + return nil, err + } var attestations []*Attestation var resp AttestationsResponse @@ -139,13 +139,11 @@ func (c *LiveClient) getAttestations(params FetchParams) ([]*Attestation, error) for url != "" && len(attestations) < params.Limit { err := backoff.Retry(func() error { newURL, restErr := c.githubAPI.RESTWithNext(c.host, http.MethodGet, url, nil, &resp) - if restErr != nil { if shouldRetry(restErr) { return restErr - } else { - return backoff.Permanent(restErr) } + return backoff.Permanent(restErr) } url = newURL From d1c4bf7dd9f02a50c851c74df7dd9ae8cb759609 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 25 Mar 2025 08:24:52 -0600 Subject: [PATCH 010/249] comment Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verify/attestation.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/attestation/verify/attestation.go b/pkg/cmd/attestation/verify/attestation.go index c09b433b0..6dd855bbc 100644 --- a/pkg/cmd/attestation/verify/attestation.go +++ b/pkg/cmd/attestation/verify/attestation.go @@ -10,6 +10,8 @@ import ( ) func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestation, string, error) { + // Fetch attestations from GitHub API within this if block since predicate type + // filter is done when the API is called if o.FetchAttestationsFromGitHubAPI() { params := api.FetchParams{ Digest: a.DigestWithAlg(), @@ -29,6 +31,8 @@ func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestatio return attestations, msg, nil } + // Fetch attestations from local bundle or OCI registry + // Predicate type filtering is done after the attestations are fetched var attestations []*api.Attestation var err error var errMsg string From e3fbe9008f8c0caf91b88ecbb3581bc96f7484ba Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 25 Mar 2025 08:25:00 -0600 Subject: [PATCH 011/249] reduce test duplication Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/api/client_test.go | 186 ++++++++++--------------- 1 file changed, 73 insertions(+), 113 deletions(-) diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go index fb2e36b4d..0b7adfcf4 100644 --- a/pkg/cmd/attestation/api/client_test.go +++ b/pkg/cmd/attestation/api/client_test.go @@ -42,95 +42,82 @@ func NewClientWithMockGHClient(hasNextPage bool) Client { } } -var testFetchParams = FetchParams{ +var testFetchParamsWithOwner = FetchParams{ Digest: testDigest, Limit: DefaultLimit, + Owner: testOwner, + PredicateType: "https://slsa.dev/provenance/v1", +} +var testFetchParamsWithRepo = FetchParams{ + Digest: testDigest, + Limit: DefaultLimit, + Repo: testRepo, PredicateType: "https://slsa.dev/provenance/v1", } +type getByTestCase struct { + name string + params FetchParams + limit int + expectedAttestations int + expectedError error + hasNextPage bool +} + +var getByTestCases = []getByTestCase{ + { + name: "get by digest with owner", + params: testFetchParamsWithOwner, + expectedAttestations: 5, + expectedError: nil, + }, + { + name: "get by digest with repo", + params: testFetchParamsWithRepo, + expectedAttestations: 5, + expectedError: nil, + }, + { + name: "get by digest with attestations greater than limit", + params: testFetchParamsWithRepo, + limit: 3, + expectedAttestations: 3, + expectedError: nil, + }, + { + name: "get by digest with next page", + params: testFetchParamsWithRepo, + limit: 30, + expectedAttestations: 10, + expectedError: nil, + hasNextPage: true, + }, + { + name: "greater than limit with next page", + params: testFetchParamsWithRepo, + limit: 7, + expectedAttestations: 7, + expectedError: nil, + hasNextPage: true, + }, +} + func TestGetByDigest(t *testing.T) { - c := NewClientWithMockGHClient(false) - testFetchParams.Repo = testRepo - attestations, err := c.GetByDigest(testFetchParams) - require.NoError(t, err) + for _, tc := range getByTestCases { + t.Run(tc.name, func(t *testing.T) { + c := NewClientWithMockGHClient(tc.hasNextPage) - require.Equal(t, 5, len(attestations)) - bundle := (attestations)[0].Bundle - require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") + if tc.limit > 0 { + tc.params.Limit = tc.limit + } + attestations, err := c.GetByDigest(tc.params) + require.NoError(t, err) - testFetchParams.Owner = testOwner - attestations, err = c.GetByDigest(testFetchParams) - require.NoError(t, err) - - require.Equal(t, 5, len(attestations)) - bundle = (attestations)[0].Bundle - require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") -} - -func TestGetByDigestGreaterThanLimit(t *testing.T) { - c := NewClientWithMockGHClient(false) - - limit := 3 - // The method should return five results when the limit is not set - testFetchParams.Limit = limit - testFetchParams.Repo = testRepo - attestations, err := c.GetByDigest(testFetchParams) - require.NoError(t, err) - - require.Equal(t, 3, len(attestations)) - bundle := (attestations)[0].Bundle - require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") - - testFetchParams.Owner = testOwner - attestations, err = c.GetByDigest(testFetchParams) - require.NoError(t, err) - - require.Equal(t, len(attestations), limit) - bundle = (attestations)[0].Bundle - require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") -} - -func TestGetByDigestWithNextPage(t *testing.T) { - c := NewClientWithMockGHClient(true) - testFetchParams.Repo = testRepo - testFetchParams.Limit = 30 - attestations, err := c.GetByDigest(testFetchParams) - require.NoError(t, err) - - require.Equal(t, len(attestations), 10) - bundle := (attestations)[0].Bundle - require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") - - testFetchParams.Owner = testOwner - attestations, err = c.GetByDigest(testFetchParams) - require.NoError(t, err) - - require.Equal(t, len(attestations), 10) - bundle = (attestations)[0].Bundle - require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") -} - -func TestGetByDigestGreaterThanLimitWithNextPage(t *testing.T) { - c := NewClientWithMockGHClient(true) - - limit := 7 - // The method should return five results when the limit is not set - testFetchParams.Limit = limit - testFetchParams.Repo = testRepo - attestations, err := c.GetByDigest(testFetchParams) - require.NoError(t, err) - - require.Equal(t, len(attestations), limit) - bundle := (attestations)[0].Bundle - require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") - - testFetchParams.Owner = testOwner - attestations, err = c.GetByDigest(testFetchParams) - require.NoError(t, err) - - require.Equal(t, len(attestations), limit) - bundle = (attestations)[0].Bundle - require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") + require.Equal(t, tc.expectedAttestations, len(attestations)) + bundle := (attestations)[0].Bundle + require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") + }) + } } func TestGetByDigest_NoAttestationsFound(t *testing.T) { @@ -147,14 +134,7 @@ func TestGetByDigest_NoAttestationsFound(t *testing.T) { logger: io.NewTestHandler(), } - testFetchParams.Repo = testRepo - attestations, err := c.GetByDigest(testFetchParams) - require.Error(t, err) - require.IsType(t, ErrNoAttestationsFound, err) - require.Nil(t, attestations) - - testFetchParams.Owner = testOwner - attestations, err = c.GetByDigest(testFetchParams) + attestations, err := c.GetByDigest(testFetchParamsWithRepo) require.Error(t, err) require.IsType(t, ErrNoAttestationsFound, err) require.Nil(t, attestations) @@ -172,13 +152,7 @@ func TestGetByDigest_Error(t *testing.T) { logger: io.NewTestHandler(), } - testFetchParams.Repo = testRepo - attestations, err := c.GetByDigest(testFetchParams) - require.Error(t, err) - require.Nil(t, attestations) - - testFetchParams.Owner = testOwner - attestations, err = c.GetByDigest(testFetchParams) + attestations, err := c.GetByDigest(testFetchParamsWithRepo) require.Error(t, err) require.Nil(t, attestations) } @@ -383,9 +357,8 @@ func TestGetAttestationsRetries(t *testing.T) { logger: io.NewTestHandler(), } - testFetchParams.Repo = testRepo - testFetchParams.Limit = 30 - attestations, err := c.GetByDigest(testFetchParams) + testFetchParamsWithRepo.Limit = 30 + attestations, err := c.GetByDigest(testFetchParamsWithRepo) require.NoError(t, err) // assert the error path was executed; because this is a paged @@ -396,18 +369,6 @@ func TestGetAttestationsRetries(t *testing.T) { require.Equal(t, len(attestations), 10) bundle := (attestations)[0].Bundle require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") - - // same test as above, but for GetByDigest: - testFetchParams.Owner = testOwner - attestations, err = c.GetByDigest(testFetchParams) - require.NoError(t, err) - - // because we haven't reset the mock, we have added 2 more failed requests - fetcher.AssertNumberOfCalls(t, "FlakyOnRESTSuccessWithNextPage:error", 4) - - require.Equal(t, len(attestations), 10) - bundle = (attestations)[0].Bundle - require.Equal(t, bundle.GetMediaType(), "application/vnd.dev.sigstore.bundle.v0.3+json") } // test total retries @@ -425,8 +386,7 @@ func TestGetAttestationsMaxRetries(t *testing.T) { logger: io.NewTestHandler(), } - testFetchParams.Repo = testRepo - _, err := c.GetByDigest(testFetchParams) + _, err := c.GetByDigest(testFetchParamsWithRepo) require.Error(t, err) fetcher.AssertNumberOfCalls(t, "OnREST500Error", 4) From 166e211e2bab796bd72594350f35956760ce14f3 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 25 Mar 2025 08:28:33 -0600 Subject: [PATCH 012/249] clean up test fixtures Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/api/client_test.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go index 0b7adfcf4..384c7c9c8 100644 --- a/pkg/cmd/attestation/api/client_test.go +++ b/pkg/cmd/attestation/api/client_test.go @@ -60,7 +60,6 @@ type getByTestCase struct { params FetchParams limit int expectedAttestations int - expectedError error hasNextPage bool } @@ -69,27 +68,22 @@ var getByTestCases = []getByTestCase{ name: "get by digest with owner", params: testFetchParamsWithOwner, expectedAttestations: 5, - expectedError: nil, }, { name: "get by digest with repo", params: testFetchParamsWithRepo, expectedAttestations: 5, - expectedError: nil, }, { name: "get by digest with attestations greater than limit", params: testFetchParamsWithRepo, limit: 3, expectedAttestations: 3, - expectedError: nil, }, { name: "get by digest with next page", params: testFetchParamsWithRepo, - limit: 30, expectedAttestations: 10, - expectedError: nil, hasNextPage: true, }, { @@ -97,7 +91,6 @@ var getByTestCases = []getByTestCase{ params: testFetchParamsWithRepo, limit: 7, expectedAttestations: 7, - expectedError: nil, hasNextPage: true, }, } From 05d9156a992b7df3b5d7fd85020f8da7d2bea863 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 1 Apr 2025 11:16:00 -0600 Subject: [PATCH 013/249] add check for nil api client Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/download/download.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/attestation/download/download.go b/pkg/cmd/attestation/download/download.go index 86cf08d72..2cb648414 100644 --- a/pkg/cmd/attestation/download/download.go +++ b/pkg/cmd/attestation/download/download.go @@ -127,6 +127,9 @@ func runDownload(opts *Options) error { opts.Logger.VerbosePrintf("Downloading trusted metadata for artifact %s\n\n", opts.ArtifactPath) + if opts.APIClient == nil { + return fmt.Errorf("no APIClient provided") + } params := api.FetchParams{ Digest: artifact.DigestWithAlg(), Limit: opts.Limit, From 13dafefcb5ddb5eaa1e634b51338256f7ac588ee Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 1 Apr 2025 11:23:25 -0600 Subject: [PATCH 014/249] add missing nil struct checks and udpate error messages Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/api/mock_client.go | 4 ++-- pkg/cmd/attestation/verification/attestation_test.go | 4 ++-- pkg/cmd/attestation/verify/attestation.go | 6 ++++++ pkg/cmd/attestation/verify/verify_test.go | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/attestation/api/mock_client.go b/pkg/cmd/attestation/api/mock_client.go index efcedc8b5..fbf44bbb9 100644 --- a/pkg/cmd/attestation/api/mock_client.go +++ b/pkg/cmd/attestation/api/mock_client.go @@ -31,9 +31,9 @@ func OnGetByDigestSuccess(params FetchParams) ([]*Attestation, error) { func OnGetByDigestFailure(params FetchParams) ([]*Attestation, error) { if params.Repo != "" { - return nil, fmt.Errorf("failed to fetch by repo and digest") + return nil, fmt.Errorf("failed to fetch attestations from %s", params.Repo) } - return nil, fmt.Errorf("failed to fetch by owner and digest") + return nil, fmt.Errorf("failed to fetch attestations from %s", params.Owner) } func NewTestClient() *MockClient { diff --git a/pkg/cmd/attestation/verification/attestation_test.go b/pkg/cmd/attestation/verification/attestation_test.go index 55a447cf4..18e2c6cca 100644 --- a/pkg/cmd/attestation/verification/attestation_test.go +++ b/pkg/cmd/attestation/verification/attestation_test.go @@ -162,6 +162,6 @@ func TestFilterAttestations(t *testing.T) { require.NoError(t, err) filtered, err = FilterAttestations("NonExistentPredicate", attestations) - require.Len(t, filtered, 0) - require.NoError(t, err) + require.Nil(t, filtered) + require.Error(t, err) } diff --git a/pkg/cmd/attestation/verify/attestation.go b/pkg/cmd/attestation/verify/attestation.go index 6dd855bbc..2a935a56c 100644 --- a/pkg/cmd/attestation/verify/attestation.go +++ b/pkg/cmd/attestation/verify/attestation.go @@ -1,6 +1,7 @@ package verify import ( + "errors" "fmt" "github.com/cli/cli/v2/internal/text" @@ -13,6 +14,11 @@ func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestatio // Fetch attestations from GitHub API within this if block since predicate type // filter is done when the API is called if o.FetchAttestationsFromGitHubAPI() { + if o.APIClient == nil { + errMsg := "✗ No APIClient provided" + return nil, errMsg, errors.New(errMsg) + } + params := api.FetchParams{ Digest: a.DigestWithAlg(), Limit: o.Limit, diff --git a/pkg/cmd/attestation/verify/verify_test.go b/pkg/cmd/attestation/verify/verify_test.go index 092a009d8..2b821a435 100644 --- a/pkg/cmd/attestation/verify/verify_test.go +++ b/pkg/cmd/attestation/verify/verify_test.go @@ -510,7 +510,7 @@ func TestRunVerify(t *testing.T) { err := runVerify(&customOpts) require.Error(t, err) - require.ErrorContains(t, err, "no matching predicate found") + require.ErrorContains(t, err, "no attestations found with predicate type") }) t.Run("with valid OCI artifact with UseBundleFromRegistry flag but no bundle return from registry", func(t *testing.T) { From f43ec0079bab00cbc2065da7468c6867511818c9 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 1 Apr 2025 11:52:13 -0600 Subject: [PATCH 015/249] add test for predicate type filtering Signed-off-by: Meredith Lancaster --- .../verify/verify-with-internal-github-sigstore.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/integration/attestation-cmd/verify/verify-with-internal-github-sigstore.sh b/test/integration/attestation-cmd/verify/verify-with-internal-github-sigstore.sh index 647a13a4c..cea3c7228 100644 --- a/test/integration/attestation-cmd/verify/verify-with-internal-github-sigstore.sh +++ b/test/integration/attestation-cmd/verify/verify-with-internal-github-sigstore.sh @@ -14,3 +14,9 @@ if ! $ghBuildPath attestation verify "$ghCLIArtifact" --digest-alg=sha256 --owne echo "Failed to verify" exit 1 fi + +# Try to verify when specifying a predicate type that does not match the attestation +if $ghBuildPath attestation verify "$ghCLIArtifact" --digest-alg=sha256 --owner=cli --predicate-type=my-custom-predicate-type; then + echo "Verification should have failed" + exit 1 +fi From 56d924d25b39bb30497ba26c268d71428d794880 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 1 Apr 2025 12:58:37 -0600 Subject: [PATCH 016/249] getAttestations unit tests Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verify/attestation.go | 18 +-- .../verify/attestation_integration_test.go | 119 ------------------ .../attestation/verify/attestation_test.go | 53 ++++++++ 3 files changed, 63 insertions(+), 127 deletions(-) delete mode 100644 pkg/cmd/attestation/verify/attestation_integration_test.go create mode 100644 pkg/cmd/attestation/verify/attestation_test.go diff --git a/pkg/cmd/attestation/verify/attestation.go b/pkg/cmd/attestation/verify/attestation.go index 2a935a56c..68d38f985 100644 --- a/pkg/cmd/attestation/verify/attestation.go +++ b/pkg/cmd/attestation/verify/attestation.go @@ -41,30 +41,32 @@ func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestatio // Predicate type filtering is done after the attestations are fetched var attestations []*api.Attestation var err error - var errMsg string + var msg string if o.BundlePath != "" { attestations, err = verification.GetLocalAttestations(o.BundlePath) if err != nil { - errMsg = fmt.Sprintf("✗ Loading attestations from %s failed", a.URL) + pluralAttestation := text.Pluralize(len(attestations), "attestation") + msg = fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.BundlePath) + } else { + msg = fmt.Sprintf("Loaded %d attestations from %s", len(attestations), o.BundlePath) } } else if o.UseBundleFromRegistry { attestations, err = verification.GetOCIAttestations(o.OCIClient, a) if err != nil { - errMsg = "✗ Loading attestations from OCI registry failed" + msg = "✗ Loading attestations from OCI registry failed" + } else { + pluralAttestation := text.Pluralize(len(attestations), "attestation") + msg = fmt.Sprintf("Loaded %s from OCI registry", pluralAttestation) } } - if err != nil { - return nil, errMsg, err + return nil, msg, err } filtered, err := verification.FilterAttestations(o.PredicateType, attestations) if err != nil { return nil, err.Error(), err } - - pluralAttestation := text.Pluralize(len(filtered), "attestation") - msg := fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.BundlePath) return filtered, msg, nil } diff --git a/pkg/cmd/attestation/verify/attestation_integration_test.go b/pkg/cmd/attestation/verify/attestation_integration_test.go deleted file mode 100644 index 9ff174141..000000000 --- a/pkg/cmd/attestation/verify/attestation_integration_test.go +++ /dev/null @@ -1,119 +0,0 @@ -//go:build integration - -package verify - -import ( - "testing" - - "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" - "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/cli/cli/v2/pkg/cmd/attestation/test" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - o "github.com/cli/cli/v2/pkg/option" - "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" - "github.com/stretchr/testify/require" -) - -func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation { - t.Helper() - - attestations, err := verification.GetLocalAttestations(bundlePath) - require.NoError(t, err) - - return attestations -} - -func TestVerifyAttestations(t *testing.T) { - sgVerifier := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ - Logger: io.NewTestHandler(), - TUFMetadataDir: o.Some(t.TempDir()), - }) - - certSummary := certificate.Summary{} - certSummary.SourceRepositoryOwnerURI = "https://github.com/sigstore" - certSummary.SourceRepositoryURI = "https://github.com/sigstore/sigstore-js" - certSummary.Issuer = verification.GitHubOIDCIssuer - - ec := verification.EnforcementCriteria{ - Certificate: certSummary, - PredicateType: verification.SLSAPredicateV1, - SANRegex: "^https://github.com/sigstore/", - } - require.NoError(t, ec.Valid()) - - artifactPath := test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0.tgz") - a, err := artifact.NewDigestedArtifact(nil, artifactPath, "sha512") - require.NoError(t, err) - - t.Run("all attestations pass verification", func(t *testing.T) { - attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") - require.Len(t, attestations, 2) - results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, ec) - require.NoError(t, err) - require.Zero(t, errMsg) - require.Len(t, results, 2) - }) - - t.Run("passes verification with 2/3 attestations passing Sigstore verification", func(t *testing.T) { - 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") - attestations = append(attestations, invalidBundle[0]) - require.Len(t, attestations, 3) - - results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, ec) - require.NoError(t, err) - require.Zero(t, errMsg) - require.Len(t, results, 2) - }) - - t.Run("fails verification when Sigstore verification fails", func(t *testing.T) { - invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json") - invalidBundle2 := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json") - attestations := append(invalidBundle, invalidBundle2...) - require.Len(t, attestations, 2) - - results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, ec) - require.Error(t, err) - require.Contains(t, errMsg, "✗ Sigstore verification failed") - require.Nil(t, results) - }) - - t.Run("attestations fail to verify when cert extensions don't match enforcement criteria", func(t *testing.T) { - sgjAttestation := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") - reusableWorkflowAttestations := getAttestationsFor(t, "../test/data/reusable-workflow-attestation.sigstore.json") - attestations := []*api.Attestation{sgjAttestation[0], reusableWorkflowAttestations[0], sgjAttestation[1]} - require.Len(t, attestations, 3) - - rwfResult := verification.BuildMockResult(reusableWorkflowAttestations[0].Bundle, "", "", "https://github.com/malancas", "", verification.GitHubOIDCIssuer) - sgjResult := verification.BuildSigstoreJsMockResult(t) - mockResults := []*verification.AttestationProcessingResult{&sgjResult, &rwfResult, &sgjResult} - mockSgVerifier := verification.NewMockSigstoreVerifierWithMockResults(t, mockResults) - - // we want to test that attestations that pass Sigstore verification but fail - // cert extension verification are filtered out properly in the second step - // in verifyAttestations. By using a mock Sigstore verifier, we can ensure - // that the call to verification.VerifyCertExtensions in verifyAttestations - // is filtering out attestations as expected - results, errMsg, err := verifyAttestations(*a, attestations, mockSgVerifier, ec) - require.NoError(t, err) - require.Zero(t, errMsg) - require.Len(t, results, 2) - for _, result := range results { - require.NotEqual(t, result.Attestation.Bundle, reusableWorkflowAttestations[0].Bundle) - } - }) - - t.Run("fails verification when cert extension verification fails", func(t *testing.T) { - attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") - require.Len(t, attestations, 2) - - expectedCriteria := ec - expectedCriteria.Certificate.SourceRepositoryOwnerURI = "https://github.com/wrong" - - results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, expectedCriteria) - require.Error(t, err) - require.Contains(t, errMsg, "✗ Policy verification failed") - require.Nil(t, results) - }) -} diff --git a/pkg/cmd/attestation/verify/attestation_test.go b/pkg/cmd/attestation/verify/attestation_test.go new file mode 100644 index 000000000..93ac7d327 --- /dev/null +++ b/pkg/cmd/attestation/verify/attestation_test.go @@ -0,0 +1,53 @@ +package verify + +import ( + "testing" + + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/stretchr/testify/require" +) + +func TestGetAttestations_OCIRegistry_PredicateTypeFiltering(t *testing.T) { + artifact, err := artifact.NewDigestedArtifact(nil, "../test/data/gh_2.60.1_windows_arm64.zip", "sha256") + require.NoError(t, err) + + o := &Options{ + OCIClient: oci.MockClient{}, + PredicateType: verification.SLSAPredicateV1, + Repo: "cli/cli", + UseBundleFromRegistry: true, + } + attestations, msg, err := getAttestations(o, *artifact) + require.NoError(t, err) + require.Contains(t, msg, "Loaded 2 attestations from OCI registry") + require.Len(t, attestations, 2) + + o.PredicateType = "custom predicate type" + attestations, msg, err = getAttestations(o, *artifact) + require.Error(t, err) + require.Contains(t, msg, "no attestations found with predicate type") + require.Nil(t, attestations) +} + +func TestGetAttestations_LocalBundle_PredicateTypeFiltering(t *testing.T) { + artifact, err := artifact.NewDigestedArtifact(nil, "../test/data/gh_2.60.1_windows_arm64.zip", "sha256") + require.NoError(t, err) + + o := &Options{ + BundlePath: "../test/data/sigstore-js-2.1.0-bundle.json", + PredicateType: verification.SLSAPredicateV1, + Repo: "sigstore/sigstore-js", + } + attestations, msg, err := getAttestations(o, *artifact) + require.NoError(t, err) + require.Contains(t, msg, "Loaded 1 attestation from ../test/data/gh_2.60.1_windows_arm64.zip") + require.Len(t, attestations, 1) + + o.PredicateType = "custom predicate type" + attestations, msg, err = getAttestations(o, *artifact) + require.Error(t, err) + require.Contains(t, msg, "no attestations found with predicate type") + require.Nil(t, attestations) +} From af87389a1970057331f6c745928f2e8982518a3d Mon Sep 17 00:00:00 2001 From: Lucas Holt Date: Sat, 29 Mar 2025 19:39:16 -0400 Subject: [PATCH 017/249] Add instructions for MidnightBSD installation --- docs/install_linux.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/install_linux.md b/docs/install_linux.md index 43ba876a1..09342c802 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -163,6 +163,20 @@ Or via [pkg(8)](https://www.freebsd.org/cgi/man.cgi?pkg(8)): pkg install gh ``` +### MidnightBSD + +MidnightBSD users can install from mports + +```bash +cd /usr/mports/devel/gh/ && make install clean +``` + +Or via [mport(1)](http://man.midnightbsd.org/cgi-bin/man.cgi/mport): + +```bash +mport install gh +``` + ### NetBSD/pkgsrc NetBSD users and those on [platforms supported by pkgsrc](https://pkgsrc.org/#index4h1) can install the [gh package](https://pkgsrc.se/net/gh): From 164a56cb663702233a4bf4e884acf8d0578d6632 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Thu, 3 Apr 2025 11:02:45 -0600 Subject: [PATCH 018/249] move filterAttestations function Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/api/attestation.go | 35 +++++++++++++++++++ pkg/cmd/attestation/api/mock_client.go | 7 +++- pkg/cmd/attestation/download/download.go | 3 +- .../attestation/verification/attestation.go | 32 ----------------- .../verification/attestation_test.go | 4 +-- pkg/cmd/attestation/verify/attestation.go | 2 +- .../attestation/verify/attestation_test.go | 26 +++++++++++--- 7 files changed, 67 insertions(+), 42 deletions(-) diff --git a/pkg/cmd/attestation/api/attestation.go b/pkg/cmd/attestation/api/attestation.go index fd6b484a7..daec12b50 100644 --- a/pkg/cmd/attestation/api/attestation.go +++ b/pkg/cmd/attestation/api/attestation.go @@ -1,7 +1,10 @@ package api import ( + "encoding/json" "errors" + "fmt" + "github.com/sigstore/sigstore-go/pkg/bundle" ) @@ -20,3 +23,35 @@ type Attestation struct { type AttestationsResponse struct { Attestations []*Attestation `json:"attestations"` } + +type IntotoStatement struct { + PredicateType string `json:"predicateType"` +} + +func FilterAttestations(predicateType string, attestations []*Attestation) ([]*Attestation, error) { + filteredAttestations := []*Attestation{} + + for _, each := range attestations { + dsseEnvelope := each.Bundle.GetDsseEnvelope() + if dsseEnvelope != nil { + if dsseEnvelope.PayloadType != "application/vnd.in-toto+json" { + // Don't fail just because an entry isn't intoto + continue + } + var intotoStatement IntotoStatement + if err := json.Unmarshal([]byte(dsseEnvelope.Payload), &intotoStatement); err != nil { + // Don't fail just because a single entry can't be unmarshalled + continue + } + if intotoStatement.PredicateType == predicateType { + filteredAttestations = append(filteredAttestations, each) + } + } + } + + if len(filteredAttestations) == 0 { + return nil, fmt.Errorf("no attestations found with predicate type: %s", predicateType) + } + + return filteredAttestations, nil +} diff --git a/pkg/cmd/attestation/api/mock_client.go b/pkg/cmd/attestation/api/mock_client.go index fbf44bbb9..b6062b39f 100644 --- a/pkg/cmd/attestation/api/mock_client.go +++ b/pkg/cmd/attestation/api/mock_client.go @@ -26,7 +26,12 @@ func (m MockClient) GetTrustDomain() (string, error) { func OnGetByDigestSuccess(params FetchParams) ([]*Attestation, error) { att1 := makeTestAttestation() att2 := makeTestAttestation() - return []*Attestation{&att1, &att2}, nil + attestations := []*Attestation{&att1, &att2} + if params.PredicateType != "" { + return FilterAttestations(params.PredicateType, attestations) + } + + return attestations, nil } func OnGetByDigestFailure(params FetchParams) ([]*Attestation, error) { diff --git a/pkg/cmd/attestation/download/download.go b/pkg/cmd/attestation/download/download.go index 2cb648414..8d1d1dc05 100644 --- a/pkg/cmd/attestation/download/download.go +++ b/pkg/cmd/attestation/download/download.go @@ -9,7 +9,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" "github.com/cli/cli/v2/pkg/cmd/attestation/auth" "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmdutil" ghauth "github.com/cli/go-gh/v2/pkg/auth" @@ -147,7 +146,7 @@ func runDownload(opts *Options) error { // Apply predicate type filter to returned attestations if opts.PredicateType != "" { - filteredAttestations, err := verification.FilterAttestations(opts.PredicateType, attestations) + filteredAttestations, err := api.FilterAttestations(opts.PredicateType, attestations) if err != nil { return fmt.Errorf("failed to filter attestations: %v", err) } diff --git a/pkg/cmd/attestation/verification/attestation.go b/pkg/cmd/attestation/verification/attestation.go index ba357a5cc..10eb02ac4 100644 --- a/pkg/cmd/attestation/verification/attestation.go +++ b/pkg/cmd/attestation/verification/attestation.go @@ -92,35 +92,3 @@ func GetOCIAttestations(client oci.Client, artifact artifact.DigestedArtifact) ( } return attestations, nil } - -type IntotoStatement struct { - PredicateType string `json:"predicateType"` -} - -func FilterAttestations(predicateType string, attestations []*api.Attestation) ([]*api.Attestation, error) { - filteredAttestations := []*api.Attestation{} - - for _, each := range attestations { - dsseEnvelope := each.Bundle.GetDsseEnvelope() - if dsseEnvelope != nil { - if dsseEnvelope.PayloadType != "application/vnd.in-toto+json" { - // Don't fail just because an entry isn't intoto - continue - } - var intotoStatement IntotoStatement - if err := json.Unmarshal([]byte(dsseEnvelope.Payload), &intotoStatement); err != nil { - // Don't fail just because a single entry can't be unmarshalled - continue - } - if intotoStatement.PredicateType == predicateType { - filteredAttestations = append(filteredAttestations, each) - } - } - } - - if len(filteredAttestations) == 0 { - return nil, fmt.Errorf("no attestations found with predicate type: %s", predicateType) - } - - return filteredAttestations, nil -} diff --git a/pkg/cmd/attestation/verification/attestation_test.go b/pkg/cmd/attestation/verification/attestation_test.go index 18e2c6cca..6826e2e40 100644 --- a/pkg/cmd/attestation/verification/attestation_test.go +++ b/pkg/cmd/attestation/verification/attestation_test.go @@ -157,11 +157,11 @@ func TestFilterAttestations(t *testing.T) { }, } - filtered, err := FilterAttestations("https://slsa.dev/provenance/v1", attestations) + filtered, err := api.FilterAttestations("https://slsa.dev/provenance/v1", attestations) require.Len(t, filtered, 1) require.NoError(t, err) - filtered, err = FilterAttestations("NonExistentPredicate", attestations) + filtered, err = api.FilterAttestations("NonExistentPredicate", attestations) require.Nil(t, filtered) require.Error(t, err) } diff --git a/pkg/cmd/attestation/verify/attestation.go b/pkg/cmd/attestation/verify/attestation.go index 68d38f985..1b98fabf3 100644 --- a/pkg/cmd/attestation/verify/attestation.go +++ b/pkg/cmd/attestation/verify/attestation.go @@ -63,7 +63,7 @@ func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestatio return nil, msg, err } - filtered, err := verification.FilterAttestations(o.PredicateType, attestations) + filtered, err := api.FilterAttestations(o.PredicateType, attestations) if err != nil { return nil, err.Error(), err } diff --git a/pkg/cmd/attestation/verify/attestation_test.go b/pkg/cmd/attestation/verify/attestation_test.go index 93ac7d327..f015805ae 100644 --- a/pkg/cmd/attestation/verify/attestation_test.go +++ b/pkg/cmd/attestation/verify/attestation_test.go @@ -3,6 +3,7 @@ package verify import ( "testing" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" @@ -40,14 +41,31 @@ func TestGetAttestations_LocalBundle_PredicateTypeFiltering(t *testing.T) { PredicateType: verification.SLSAPredicateV1, Repo: "sigstore/sigstore-js", } - attestations, msg, err := getAttestations(o, *artifact) + attestations, _, err := getAttestations(o, *artifact) require.NoError(t, err) - require.Contains(t, msg, "Loaded 1 attestation from ../test/data/gh_2.60.1_windows_arm64.zip") require.Len(t, attestations, 1) o.PredicateType = "custom predicate type" - attestations, msg, err = getAttestations(o, *artifact) + attestations, _, err = getAttestations(o, *artifact) + require.Error(t, err) + require.Nil(t, attestations) +} + +func TestGetAttestations_GhAPI_NoAttestationsFound(t *testing.T) { + artifact, err := artifact.NewDigestedArtifact(nil, "../test/data/gh_2.60.1_windows_arm64.zip", "sha256") + require.NoError(t, err) + + o := &Options{ + APIClient: api.NewTestClient(), + PredicateType: verification.SLSAPredicateV1, + Repo: "sigstore/sigstore-js", + } + attestations, _, err := getAttestations(o, *artifact) + require.NoError(t, err) + require.Len(t, attestations, 2) + + o.PredicateType = "custom predicate type" + attestations, _, err = getAttestations(o, *artifact) require.Error(t, err) - require.Contains(t, msg, "no attestations found with predicate type") require.Nil(t, attestations) } From 69507282d251ffde053baa28eb26e644ed4a3be5 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Thu, 3 Apr 2025 11:07:06 -0600 Subject: [PATCH 019/249] restore deleted file Signed-off-by: Meredith Lancaster --- .../verify/attestation_integration_test.go | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 pkg/cmd/attestation/verify/attestation_integration_test.go diff --git a/pkg/cmd/attestation/verify/attestation_integration_test.go b/pkg/cmd/attestation/verify/attestation_integration_test.go new file mode 100644 index 000000000..9ff174141 --- /dev/null +++ b/pkg/cmd/attestation/verify/attestation_integration_test.go @@ -0,0 +1,119 @@ +//go:build integration + +package verify + +import ( + "testing" + + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/test" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + o "github.com/cli/cli/v2/pkg/option" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/stretchr/testify/require" +) + +func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation { + t.Helper() + + attestations, err := verification.GetLocalAttestations(bundlePath) + require.NoError(t, err) + + return attestations +} + +func TestVerifyAttestations(t *testing.T) { + sgVerifier := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ + Logger: io.NewTestHandler(), + TUFMetadataDir: o.Some(t.TempDir()), + }) + + certSummary := certificate.Summary{} + certSummary.SourceRepositoryOwnerURI = "https://github.com/sigstore" + certSummary.SourceRepositoryURI = "https://github.com/sigstore/sigstore-js" + certSummary.Issuer = verification.GitHubOIDCIssuer + + ec := verification.EnforcementCriteria{ + Certificate: certSummary, + PredicateType: verification.SLSAPredicateV1, + SANRegex: "^https://github.com/sigstore/", + } + require.NoError(t, ec.Valid()) + + artifactPath := test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0.tgz") + a, err := artifact.NewDigestedArtifact(nil, artifactPath, "sha512") + require.NoError(t, err) + + t.Run("all attestations pass verification", func(t *testing.T) { + attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") + require.Len(t, attestations, 2) + results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, ec) + require.NoError(t, err) + require.Zero(t, errMsg) + require.Len(t, results, 2) + }) + + t.Run("passes verification with 2/3 attestations passing Sigstore verification", func(t *testing.T) { + 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") + attestations = append(attestations, invalidBundle[0]) + require.Len(t, attestations, 3) + + results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, ec) + require.NoError(t, err) + require.Zero(t, errMsg) + require.Len(t, results, 2) + }) + + t.Run("fails verification when Sigstore verification fails", func(t *testing.T) { + invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json") + invalidBundle2 := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json") + attestations := append(invalidBundle, invalidBundle2...) + require.Len(t, attestations, 2) + + results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, ec) + require.Error(t, err) + require.Contains(t, errMsg, "✗ Sigstore verification failed") + require.Nil(t, results) + }) + + t.Run("attestations fail to verify when cert extensions don't match enforcement criteria", func(t *testing.T) { + sgjAttestation := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") + reusableWorkflowAttestations := getAttestationsFor(t, "../test/data/reusable-workflow-attestation.sigstore.json") + attestations := []*api.Attestation{sgjAttestation[0], reusableWorkflowAttestations[0], sgjAttestation[1]} + require.Len(t, attestations, 3) + + rwfResult := verification.BuildMockResult(reusableWorkflowAttestations[0].Bundle, "", "", "https://github.com/malancas", "", verification.GitHubOIDCIssuer) + sgjResult := verification.BuildSigstoreJsMockResult(t) + mockResults := []*verification.AttestationProcessingResult{&sgjResult, &rwfResult, &sgjResult} + mockSgVerifier := verification.NewMockSigstoreVerifierWithMockResults(t, mockResults) + + // we want to test that attestations that pass Sigstore verification but fail + // cert extension verification are filtered out properly in the second step + // in verifyAttestations. By using a mock Sigstore verifier, we can ensure + // that the call to verification.VerifyCertExtensions in verifyAttestations + // is filtering out attestations as expected + results, errMsg, err := verifyAttestations(*a, attestations, mockSgVerifier, ec) + require.NoError(t, err) + require.Zero(t, errMsg) + require.Len(t, results, 2) + for _, result := range results { + require.NotEqual(t, result.Attestation.Bundle, reusableWorkflowAttestations[0].Bundle) + } + }) + + t.Run("fails verification when cert extension verification fails", func(t *testing.T) { + attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") + require.Len(t, attestations, 2) + + expectedCriteria := ec + expectedCriteria.Certificate.SourceRepositoryOwnerURI = "https://github.com/wrong" + + results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, expectedCriteria) + require.Error(t, err) + require.Contains(t, errMsg, "✗ Policy verification failed") + require.Nil(t, results) + }) +} From 06d22d96c01afae5aaaa4ff8e7895f39fa52d293 Mon Sep 17 00:00:00 2001 From: Barak Amar Date: Fri, 4 Apr 2025 11:14:02 +0300 Subject: [PATCH 020/249] handle find pr number 0 --- pkg/cmd/pr/shared/finder.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index a54528527..6e0ea0401 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -245,7 +245,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err } var pr *api.PullRequest - if f.prNumber > 0 { + if f.prNumber > 0 || f.branchName == "" { if numberFieldOnly { // avoid hitting the API if we already have all the information return &api.PullRequest{Number: f.prNumber}, f.baseRefRepo, nil From 747f015f48e1c063fae68b6041bf39cd83bea2b8 Mon Sep 17 00:00:00 2001 From: Barak Amar Date: Mon, 7 Apr 2025 22:38:28 +0300 Subject: [PATCH 021/249] test pr number 0 --- pkg/cmd/pr/shared/finder_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 36551ab42..25a948416 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -89,6 +89,19 @@ func TestFind(t *testing.T) { wantPR: 13, wantRepo: "https://github.com/ORIGINOWNER/REPO", }, + { + name: "PR number 0 is invalid", + args: args{ + selector: "0", + fields: []string{"id", "number"}, + baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil), + branchFn: func() (string, error) { + return "blueberries", nil + }, + branchConfig: stubBranchConfig(git.BranchConfig{}, nil), + }, + wantErr: true, + }, { name: "number argument with base branch", args: args{ From abd573ac66e8e4abd18cdc7abaef7deb60502fc6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:40:03 -0600 Subject: [PATCH 022/249] feat(preview): add `preview prompter` command --- pkg/cmd/preview/preview.go | 20 +++ pkg/cmd/preview/prompter/prompter.go | 225 +++++++++++++++++++++++++++ pkg/cmd/root/root.go | 2 + 3 files changed, 247 insertions(+) create mode 100644 pkg/cmd/preview/preview.go create mode 100644 pkg/cmd/preview/prompter/prompter.go diff --git a/pkg/cmd/preview/preview.go b/pkg/cmd/preview/preview.go new file mode 100644 index 000000000..5c3209722 --- /dev/null +++ b/pkg/cmd/preview/preview.go @@ -0,0 +1,20 @@ +package preview + +import ( + cmdPrompter "github.com/cli/cli/v2/pkg/cmd/preview/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdPreview(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "preview ", + Short: "Execute previews for gh features", + } + + cmdutil.DisableAuthCheck(cmd) + + cmd.AddCommand(cmdPrompter.NewCmdPrompter(f, nil)) + + return cmd +} diff --git a/pkg/cmd/preview/prompter/prompter.go b/pkg/cmd/preview/prompter/prompter.go new file mode 100644 index 000000000..97fffb23a --- /dev/null +++ b/pkg/cmd/preview/prompter/prompter.go @@ -0,0 +1,225 @@ +package prompter + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type prompterOptions struct { + IO *iostreams.IOStreams + Config func() (gh.Config, error) + + PromptsToRun []func(prompter.Prompter) error +} + +func NewCmdPrompter(f *cmdutil.Factory, runF func(*prompterOptions) error) *cobra.Command { + opts := &prompterOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + const ( + selectPrompt = "select" + multiSelectPrompt = "multi-select" + inputPrompt = "input" + passwordPrompt = "password" + confirmPrompt = "confirm" + authTokenPrompt = "auth-token" + confirmDeletionPrompt = "confirm-deletion" + inputHostnamePrompt = "input-hostname" + markdownEditorPrompt = "markdown-editor" + ) + + prompterTypeFuncMap := map[string]func(prompter.Prompter) error{ + selectPrompt: runSelect, + multiSelectPrompt: runMultiSelect, + inputPrompt: runInput, + passwordPrompt: runPassword, + confirmPrompt: runConfirm, + authTokenPrompt: runAuthToken, + confirmDeletionPrompt: runConfirmDeletion, + inputHostnamePrompt: runInputHostname, + markdownEditorPrompt: runMarkdownEditor, + } + + cmd := &cobra.Command{ + Use: "prompter [prompt type]", + Short: "Execute a test program to preview the prompter", + Long: heredoc.Doc(` + Execute a test program to preview the prompter. + Without an argument, all prompts will be run. + + Available prompt types: + - select + - multi-select + - input + - password + - confirm + - auth-token + - confirm-deletion + - input-hostname + - markdown-editor + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + + if len(args) == 0 { + // All prompts + for _, f := range prompterTypeFuncMap { + opts.PromptsToRun = append(opts.PromptsToRun, f) + } + } else { + // Only the one specified + for _, arg := range args { + f, ok := prompterTypeFuncMap[arg] + if !ok { + return fmt.Errorf("unknown prompter type: %q", arg) + } + opts.PromptsToRun = append(opts.PromptsToRun, f) + } + } + + return prompterRun(opts) + }, + } + + return cmd +} + +func prompterRun(opts *prompterOptions) error { + editor, err := cmdutil.DetermineEditor(opts.Config) + if err != nil { + return err + } + + p := prompter.New(editor, opts.IO.In, opts.IO.Out, opts.IO.ErrOut) + + for _, f := range opts.PromptsToRun { + if err := f(p); err != nil { + return err + } + // Newline for readability + fmt.Println() + } + + return nil +} + +func runSelect(p prompter.Prompter) error { + fmt.Println("Demonstrating Single Select") + cuisines := []string{"Italian", "Greek", "Indian", "Japanese", "American"} + favorite, err := p.Select("Favorite cuisine?", "Italian", cuisines) + if err != nil { + return err + } + fmt.Printf("Favorite cuisine: %s\n", cuisines[favorite]) + return nil +} + +func runMultiSelect(p prompter.Prompter) error { + fmt.Println("Demonstrating Multi Select") + cuisines := []string{"Italian", "Greek", "Indian", "Japanese", "American"} + favorites, err := p.MultiSelect("Favorite cuisines?", []string{}, cuisines) + if err != nil { + return err + } + for _, f := range favorites { + fmt.Printf("Favorite cuisine: %s\n", cuisines[f]) + } + return nil +} + +func runInput(p prompter.Prompter) error { + fmt.Println("Demonstrating Text Input") + text, err := p.Input("Favorite meal?", "Breakfast") + if err != nil { + return err + } + fmt.Printf("You typed: %s\n", text) + return nil +} + +func runPassword(p prompter.Prompter) error { + fmt.Println("Demonstrating Password Input") + safeword, err := p.Password("Safe word?") + if err != nil { + return err + } + fmt.Printf("Safe word: %s\n", safeword) + return nil +} + +func runConfirm(p prompter.Prompter) error { + fmt.Println("Demonstrating Confirmation") + confirmation, err := p.Confirm("Are you sure?", true) + if err != nil { + return err + } + fmt.Printf("Confirmation: %t\n", confirmation) + return nil +} + +func runAuthToken(p prompter.Prompter) error { + fmt.Println("Demonstrating Auth Token (can't be blank)") + token, err := p.AuthToken() + if err != nil { + return err + } + fmt.Printf("Auth token: %s\n", token) + return nil +} + +func runConfirmDeletion(p prompter.Prompter) error { + fmt.Println("Demonstrating Deletion Confirmation") + err := p.ConfirmDeletion("delete-me") + if err != nil { + return err + } + fmt.Println("Item deleted") + return nil +} + +func runInputHostname(p prompter.Prompter) error { + fmt.Println("Demonstrating Hostname") + hostname, err := p.InputHostname() + if err != nil { + return err + } + fmt.Printf("Hostname: %s\n", hostname) + return nil +} + +func runMarkdownEditor(p prompter.Prompter) error { + defaultText := "default text value" + + fmt.Println("Demonstrating Markdown Editor with blanks allowed and default text") + editorText, err := p.MarkdownEditor("Edit your text:", defaultText, true) + if err != nil { + return err + } + fmt.Printf("Returned text: %s\n", editorText) + + fmt.Println("Demonstrating Markdown Editor with blanks disallowed and default text") + editorText2, err := p.MarkdownEditor("Edit your text:", defaultText, false) + if err != nil { + return err + } + fmt.Printf("Returned text: %s\n", editorText2) + + fmt.Println("Demonstrating Markdown Editor with blanks disallowed and no default text") + editorText3, err := p.MarkdownEditor("Edit your text:", "", false) + if err != nil { + return err + } + fmt.Printf("Returned text: %s\n", editorText3) + return nil +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index c0dad93ec..ce142b32d 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -25,6 +25,7 @@ import ( labelCmd "github.com/cli/cli/v2/pkg/cmd/label" orgCmd "github.com/cli/cli/v2/pkg/cmd/org" prCmd "github.com/cli/cli/v2/pkg/cmd/pr" + previewCmd "github.com/cli/cli/v2/pkg/cmd/preview" projectCmd "github.com/cli/cli/v2/pkg/cmd/project" releaseCmd "github.com/cli/cli/v2/pkg/cmd/release" repoCmd "github.com/cli/cli/v2/pkg/cmd/repo" @@ -139,6 +140,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(statusCmd.NewCmdStatus(f, nil)) cmd.AddCommand(codespaceCmd.NewCmdCodespace(f)) cmd.AddCommand(projectCmd.NewCmdProject(f)) + cmd.AddCommand(previewCmd.NewCmdPreview(f)) // below here at the commands that require the "intelligent" BaseRepo resolver repoResolvingCmdFactory := *f From 54a929dcd9684dc11dc23b68f09e4ed04821faa1 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 16 Apr 2025 15:49:30 -0600 Subject: [PATCH 023/249] feat(preview): enforce fixed order for prompts --- pkg/cmd/preview/prompter/prompter.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/preview/prompter/prompter.go b/pkg/cmd/preview/prompter/prompter.go index 97fffb23a..5b9b91f23 100644 --- a/pkg/cmd/preview/prompter/prompter.go +++ b/pkg/cmd/preview/prompter/prompter.go @@ -48,6 +48,18 @@ func NewCmdPrompter(f *cmdutil.Factory, runF func(*prompterOptions) error) *cobr markdownEditorPrompt: runMarkdownEditor, } + allPromptsOrder := []string{ + selectPrompt, + multiSelectPrompt, + inputPrompt, + passwordPrompt, + confirmPrompt, + authTokenPrompt, + confirmDeletionPrompt, + inputHostnamePrompt, + markdownEditorPrompt, + } + cmd := &cobra.Command{ Use: "prompter [prompt type]", Short: "Execute a test program to preview the prompter", @@ -73,8 +85,9 @@ func NewCmdPrompter(f *cmdutil.Factory, runF func(*prompterOptions) error) *cobr } if len(args) == 0 { - // All prompts - for _, f := range prompterTypeFuncMap { + // All prompts, in a fixed order + for _, promptType := range allPromptsOrder { + f := prompterTypeFuncMap[promptType] opts.PromptsToRun = append(opts.PromptsToRun, f) } } else { @@ -206,14 +219,14 @@ func runMarkdownEditor(p prompter.Prompter) error { if err != nil { return err } - fmt.Printf("Returned text: %s\n", editorText) + fmt.Printf("Returned text: %s\n\n", editorText) fmt.Println("Demonstrating Markdown Editor with blanks disallowed and default text") editorText2, err := p.MarkdownEditor("Edit your text:", defaultText, false) if err != nil { return err } - fmt.Printf("Returned text: %s\n", editorText2) + fmt.Printf("Returned text: %s\n\n", editorText2) fmt.Println("Demonstrating Markdown Editor with blanks disallowed and no default text") editorText3, err := p.MarkdownEditor("Edit your text:", "", false) From aaddcb019dee7ecbf4543ed9be6a2793b278364f Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 16 Apr 2025 13:39:34 +0200 Subject: [PATCH 024/249] Issue edit early arg parsing --- pkg/cmd/issue/edit/edit.go | 33 ++++-- pkg/cmd/issue/edit/edit_test.go | 89 +++++++++----- pkg/cmd/issue/shared/lookup.go | 176 ++++++++++++++++++---------- pkg/cmd/issue/shared/lookup_test.go | 159 +++++++++++++++---------- 4 files changed, 298 insertions(+), 159 deletions(-) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 18067319f..accea8add 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -28,7 +28,7 @@ type EditOptions struct { EditFieldsSurvey func(prShared.EditPrompter, *prShared.Editable, string) error FetchOptions func(*api.Client, ghrepo.Interface, *prShared.Editable) error - SelectorArgs []string + IssueNumbers []int Interactive bool prShared.Editable @@ -69,10 +69,22 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman `), Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + issueNumbers, baseRepo, err := shared.ParseIssuesFromArgs(args) + if err != nil { + return err + } - opts.SelectorArgs = args + // If the args provided the base repo then use that directly. + if baseRepo, present := baseRepo.Value(); present { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return baseRepo, nil + } + } else { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + } + + opts.IssueNumbers = issueNumbers flags := cmd.Flags() @@ -134,7 +146,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman return cmdutil.FlagErrorf("field to edit flag required when not running interactively") } - if opts.Interactive && len(opts.SelectorArgs) > 1 { + if opts.Interactive && len(opts.IssueNumbers) > 1 { return cmdutil.FlagErrorf("multiple issues cannot be edited interactively") } @@ -167,6 +179,11 @@ func editRun(opts *EditOptions) error { return err } + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + // Prompt the user which fields they'd like to edit. editable := opts.Editable if opts.Interactive { @@ -192,7 +209,7 @@ func editRun(opts *EditOptions) error { } // Get all specified issues and make sure they are within the same repo. - issues, repo, err := shared.IssuesFromArgsWithFields(httpClient, opts.BaseRepo, opts.SelectorArgs, lookupFields) + issues, err := shared.FindIssuesOrPRs(httpClient, baseRepo, opts.IssueNumbers, lookupFields) if err != nil { return err } @@ -200,7 +217,7 @@ func editRun(opts *EditOptions) error { // Fetch editable shared fields once for all issues. apiClient := api.NewClientFromHTTP(httpClient) opts.IO.StartProgressIndicatorWithLabel("Fetching repository information") - err = opts.FetchOptions(apiClient, repo, &editable) + err = opts.FetchOptions(apiClient, baseRepo, &editable) opts.IO.StopProgressIndicator() if err != nil { return err @@ -250,7 +267,7 @@ func editRun(opts *EditOptions) error { go func(issue *api.Issue) { defer g.Done() - err := prShared.UpdateIssue(httpClient, repo, issue.ID, issue.IsPullRequest(), editable) + err := prShared.UpdateIssue(httpClient, baseRepo, issue.ID, issue.IsPullRequest(), editable) if err != nil { failedIssueChan <- fmt.Sprintf("failed to update %s: %s", issue.URL, err) return diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 40fe6491c..a9d43c3ec 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -26,11 +26,12 @@ func TestNewCmdEdit(t *testing.T) { require.NoError(t, err) tests := []struct { - name string - input string - stdin string - output EditOptions - wantsErr bool + name string + input string + stdin string + output EditOptions + expectedBaseRepo ghrepo.Interface + wantsErr bool }{ { name: "no argument", @@ -42,7 +43,7 @@ func TestNewCmdEdit(t *testing.T) { name: "issue number argument", input: "23", output: EditOptions{ - SelectorArgs: []string{"23"}, + IssueNumbers: []int{23}, Interactive: true, }, wantsErr: false, @@ -51,7 +52,7 @@ func TestNewCmdEdit(t *testing.T) { name: "title flag", input: "23 --title test", output: EditOptions{ - SelectorArgs: []string{"23"}, + IssueNumbers: []int{23}, Editable: prShared.Editable{ Title: prShared.EditableString{ Value: "test", @@ -65,7 +66,7 @@ func TestNewCmdEdit(t *testing.T) { name: "body flag", input: "23 --body test", output: EditOptions{ - SelectorArgs: []string{"23"}, + IssueNumbers: []int{23}, Editable: prShared.Editable{ Body: prShared.EditableString{ Value: "test", @@ -80,7 +81,7 @@ func TestNewCmdEdit(t *testing.T) { input: "23 --body-file -", stdin: "this is on standard input", output: EditOptions{ - SelectorArgs: []string{"23"}, + IssueNumbers: []int{23}, Editable: prShared.Editable{ Body: prShared.EditableString{ Value: "this is on standard input", @@ -94,7 +95,7 @@ func TestNewCmdEdit(t *testing.T) { name: "body from file", input: fmt.Sprintf("23 --body-file '%s'", tmpFile), output: EditOptions{ - SelectorArgs: []string{"23"}, + IssueNumbers: []int{23}, Editable: prShared.Editable{ Body: prShared.EditableString{ Value: "a body from file", @@ -113,7 +114,7 @@ func TestNewCmdEdit(t *testing.T) { name: "add-assignee flag", input: "23 --add-assignee monalisa,hubot", output: EditOptions{ - SelectorArgs: []string{"23"}, + IssueNumbers: []int{23}, Editable: prShared.Editable{ Assignees: prShared.EditableSlice{ Add: []string{"monalisa", "hubot"}, @@ -127,7 +128,7 @@ func TestNewCmdEdit(t *testing.T) { name: "remove-assignee flag", input: "23 --remove-assignee monalisa,hubot", output: EditOptions{ - SelectorArgs: []string{"23"}, + IssueNumbers: []int{23}, Editable: prShared.Editable{ Assignees: prShared.EditableSlice{ Remove: []string{"monalisa", "hubot"}, @@ -141,7 +142,7 @@ func TestNewCmdEdit(t *testing.T) { name: "add-label flag", input: "23 --add-label feature,TODO,bug", output: EditOptions{ - SelectorArgs: []string{"23"}, + IssueNumbers: []int{23}, Editable: prShared.Editable{ Labels: prShared.EditableSlice{ Add: []string{"feature", "TODO", "bug"}, @@ -155,7 +156,7 @@ func TestNewCmdEdit(t *testing.T) { name: "remove-label flag", input: "23 --remove-label feature,TODO,bug", output: EditOptions{ - SelectorArgs: []string{"23"}, + IssueNumbers: []int{23}, Editable: prShared.Editable{ Labels: prShared.EditableSlice{ Remove: []string{"feature", "TODO", "bug"}, @@ -169,7 +170,7 @@ func TestNewCmdEdit(t *testing.T) { name: "add-project flag", input: "23 --add-project Cleanup,Roadmap", output: EditOptions{ - SelectorArgs: []string{"23"}, + IssueNumbers: []int{23}, Editable: prShared.Editable{ Projects: prShared.EditableProjects{ EditableSlice: prShared.EditableSlice{ @@ -185,7 +186,7 @@ func TestNewCmdEdit(t *testing.T) { name: "remove-project flag", input: "23 --remove-project Cleanup,Roadmap", output: EditOptions{ - SelectorArgs: []string{"23"}, + IssueNumbers: []int{23}, Editable: prShared.Editable{ Projects: prShared.EditableProjects{ EditableSlice: prShared.EditableSlice{ @@ -201,7 +202,7 @@ func TestNewCmdEdit(t *testing.T) { name: "milestone flag", input: "23 --milestone GA", output: EditOptions{ - SelectorArgs: []string{"23"}, + IssueNumbers: []int{23}, Editable: prShared.Editable{ Milestone: prShared.EditableString{ Value: "GA", @@ -215,7 +216,7 @@ func TestNewCmdEdit(t *testing.T) { name: "remove-milestone flag", input: "23 --remove-milestone", output: EditOptions{ - SelectorArgs: []string{"23"}, + IssueNumbers: []int{23}, Editable: prShared.Editable{ Milestone: prShared.EditableString{ Value: "", @@ -234,7 +235,7 @@ func TestNewCmdEdit(t *testing.T) { name: "add label to multiple issues", input: "23 34 --add-label bug", output: EditOptions{ - SelectorArgs: []string{"23", "34"}, + IssueNumbers: []int{23, 34}, Editable: prShared.Editable{ Labels: prShared.EditableSlice{ Add: []string{"bug"}, @@ -244,6 +245,31 @@ func TestNewCmdEdit(t *testing.T) { }, wantsErr: false, }, + { + name: "argument is hash prefixed number", + // Escaping is required here to avoid what I think is shellex treating it as a comment. + input: "\\#23", + output: EditOptions{ + IssueNumbers: []int{23}, + Interactive: true, + }, + wantsErr: false, + }, + { + name: "argument is a URL", + input: "https://github.com/cli/cli/issues/23", + output: EditOptions{ + IssueNumbers: []int{23}, + Interactive: true, + }, + expectedBaseRepo: ghrepo.New("cli", "cli"), + wantsErr: false, + }, + { + name: "URL arguments parse as different repos", + input: "https://github.com/cli/cli/issues/23 https://github.com/cli/go-gh/issues/23", + wantsErr: true, + }, { name: "interactive multiple issues", input: "23 34", @@ -282,14 +308,23 @@ func TestNewCmdEdit(t *testing.T) { _, err = cmd.ExecuteC() if tt.wantsErr { - assert.Error(t, err) + require.Error(t, err) return } - assert.NoError(t, err) - assert.Equal(t, tt.output.SelectorArgs, gotOpts.SelectorArgs) + require.NoError(t, err) + assert.Equal(t, tt.output.IssueNumbers, gotOpts.IssueNumbers) assert.Equal(t, tt.output.Interactive, gotOpts.Interactive) assert.Equal(t, tt.output.Editable, gotOpts.Editable) + if tt.expectedBaseRepo != nil { + baseRepo, err := gotOpts.BaseRepo() + require.NoError(t, err) + require.True( + t, + ghrepo.IsSame(tt.expectedBaseRepo, baseRepo), + "expected base repo %+v, got %+v", tt.expectedBaseRepo, baseRepo, + ) + } }) } } @@ -306,7 +341,7 @@ func Test_editRun(t *testing.T) { { name: "non-interactive", input: &EditOptions{ - SelectorArgs: []string{"123"}, + IssueNumbers: []int{123}, Interactive: false, Editable: prShared.Editable{ Title: prShared.EditableString{ @@ -359,7 +394,7 @@ func Test_editRun(t *testing.T) { { name: "non-interactive multiple issues", input: &EditOptions{ - SelectorArgs: []string{"456", "123"}, + IssueNumbers: []int{456, 123}, Interactive: false, Editable: prShared.Editable{ Assignees: prShared.EditableSlice{ @@ -409,7 +444,7 @@ func Test_editRun(t *testing.T) { { name: "non-interactive multiple issues with fetch failures", input: &EditOptions{ - SelectorArgs: []string{"123", "9999"}, + IssueNumbers: []int{123, 9999}, Interactive: false, Editable: prShared.Editable{ Assignees: prShared.EditableSlice{ @@ -454,7 +489,7 @@ func Test_editRun(t *testing.T) { { name: "non-interactive multiple issues with update failures", input: &EditOptions{ - SelectorArgs: []string{"123", "456"}, + IssueNumbers: []int{123, 456}, Interactive: false, Editable: prShared.Editable{ Assignees: prShared.EditableSlice{ @@ -524,7 +559,7 @@ func Test_editRun(t *testing.T) { { name: "interactive", input: &EditOptions{ - SelectorArgs: []string{"123"}, + IssueNumbers: []int{123}, Interactive: true, FieldsToEditSurvey: func(p prShared.EditPrompter, eo *prShared.Editable) error { eo.Title.Edited = true diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index be79f9a73..bff6018d9 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -13,10 +13,89 @@ import ( "github.com/cli/cli/v2/api" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" + o "github.com/cli/cli/v2/pkg/option" "github.com/cli/cli/v2/pkg/set" "golang.org/x/sync/errgroup" ) +func ParseIssuesFromArgs(args []string) ([]int, o.Option[ghrepo.Interface], error) { + var repo o.Option[ghrepo.Interface] + issueNumbers := make([]int, len(args)) + + for i, arg := range args { + // For each argument, parse the issue number and an optional repo + issueNumber, issueRepo, err := parseIssueFromArg(arg) + if err != nil { + return nil, o.None[ghrepo.Interface](), err + } + + // if this is our first issue repo found, then we need to set it + if repo.IsNone() { + repo = issueRepo + } + + // if there is an issue repo returned, then we need to check if it is the same as the previous one + if issueRepo.IsSome() && repo.IsSome() { + // Unwraps are safe because we've checked for presence above + if !ghrepo.IsSame(repo.Unwrap(), issueRepo.Unwrap()) { + return nil, o.None[ghrepo.Interface](), fmt.Errorf( + "multiple issues must be in same repo: found %q, expected %q", + ghrepo.FullName(issueRepo.Unwrap()), + ghrepo.FullName(repo.Unwrap()), + ) + } + } + + // add the issue number to the list + issueNumbers[i] = issueNumber + } + + return issueNumbers, repo, nil +} + +func parseIssueFromArg(arg string) (int, o.Option[ghrepo.Interface], error) { + issueLocator := tryParseIssueFromURL(arg) + if issueLocator, present := issueLocator.Value(); present { + return issueLocator.issueNumber, o.Some(issueLocator.repo), nil + } + + issueNumber, err := strconv.Atoi(strings.TrimPrefix(arg, "#")) + if err != nil { + return 0, o.None[ghrepo.Interface](), fmt.Errorf("invalid issue format: %q", arg) + } + + return issueNumber, o.None[ghrepo.Interface](), nil +} + +type issueLocator struct { + issueNumber int + repo ghrepo.Interface +} + +// tryParseIssueFromURL tries to parse an issue number and repo from a URL. +func tryParseIssueFromURL(maybeURL string) o.Option[issueLocator] { + u, err := url.Parse(maybeURL) + if err != nil { + return o.None[issueLocator]() + } + + if u.Scheme != "https" && u.Scheme != "http" { + return o.None[issueLocator]() + } + + m := issueURLRE.FindStringSubmatch(u.Path) + if m == nil { + return o.None[issueLocator]() + } + + repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname()) + issueNumber, _ := strconv.Atoi(m[3]) + return o.Some(issueLocator{ + issueNumber: issueNumber, + repo: repo, + }) +} + // IssueFromArgWithFields loads an issue or pull request with the specified fields. If some of the fields // could not be fetched by GraphQL, this returns a non-nil issue and a *PartialLoadError. func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), arg string, fields []string) (*api.Issue, ghrepo.Interface, error) { @@ -36,70 +115,6 @@ func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.I return issue, baseRepo, err } -// IssuesFromArgsWithFields loads 1 or more issues or pull requests with the specified fields. If some of the fields -// could not be fetched by GraphQL, this returns non-nil issues and a *PartialLoadError. -func IssuesFromArgsWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), args []string, fields []string) ([]*api.Issue, ghrepo.Interface, error) { - var issuesRepo ghrepo.Interface - issueNumbers := make([]int, 0, len(args)) - - for _, arg := range args { - issueNumber, baseRepo, err := IssueNumberAndRepoFromArg(arg) - if err != nil { - return nil, nil, err - } - - issueNumbers = append(issueNumbers, issueNumber) - if baseRepo == nil { - var err error - if baseRepo, err = baseRepoFn(); err != nil { - return nil, nil, err - } - } - - if issuesRepo == nil { - issuesRepo = baseRepo - continue - } - - if !ghrepo.IsSame(issuesRepo, baseRepo) { - return nil, nil, fmt.Errorf( - "multiple issues must be in same repo: found %q, expected %q", - ghrepo.FullName(baseRepo), - ghrepo.FullName(issuesRepo), - ) - } - } - - issuesChan := make(chan *api.Issue, len(args)) - g := errgroup.Group{} - for _, num := range issueNumbers { - issueNumber := num - g.Go(func() error { - issue, err := findIssueOrPR(httpClient, issuesRepo, issueNumber, fields) - if err != nil { - return err - } - - issuesChan <- issue - return nil - }) - } - - err := g.Wait() - close(issuesChan) - - if err != nil { - return nil, nil, err - } - - issues := make([]*api.Issue, 0, len(args)) - for issue := range issuesChan { - issues = append(issues, issue) - } - - return issues, issuesRepo, nil -} - var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/(?:issues|pull)/(\d+)`) func issueMetadataFromURL(s string) (int, ghrepo.Interface) { @@ -142,6 +157,39 @@ type PartialLoadError struct { error } +// FindIssuesOrPRs loads 1 or more issues or pull requests with the specified fields. If some of the fields +// could not be fetched by GraphQL, this returns non-nil issues and a *PartialLoadError. +func FindIssuesOrPRs(httpClient *http.Client, repo ghrepo.Interface, issueNumbers []int, fields []string) ([]*api.Issue, error) { + issuesChan := make(chan *api.Issue, len(issueNumbers)) + g := errgroup.Group{} + for _, num := range issueNumbers { + issueNumber := num + g.Go(func() error { + issue, err := findIssueOrPR(httpClient, repo, issueNumber, fields) + if err != nil { + return err + } + + issuesChan <- issue + return nil + }) + } + + err := g.Wait() + close(issuesChan) + + if err != nil { + return nil, err + } + + issues := make([]*api.Issue, 0, len(issueNumbers)) + for issue := range issuesChan { + issues = append(issues, issue) + } + + return issues, nil +} + func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.Issue, error) { fieldSet := set.NewStringSet() fieldSet.AddValues(fields) diff --git a/pkg/cmd/issue/shared/lookup_test.go b/pkg/cmd/issue/shared/lookup_test.go index 44f496de4..d9e8bc31a 100644 --- a/pkg/cmd/issue/shared/lookup_test.go +++ b/pkg/cmd/issue/shared/lookup_test.go @@ -7,7 +7,9 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" + o "github.com/cli/cli/v2/pkg/option" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIssueFromArgWithFields(t *testing.T) { @@ -214,53 +216,85 @@ func TestIssueFromArgWithFields(t *testing.T) { } } -func TestIssuesFromArgsWithFields(t *testing.T) { - type args struct { - baseRepoFn func() (ghrepo.Interface, error) - selectors []string - } +func TestParseIssuesFromArgs(t *testing.T) { tests := []struct { - name string - args args - httpStub func(*httpmock.Registry) - wantIssues []int - wantRepo string - wantErr bool - wantErrMsg string + behavior string + args []string + expectedIssueNumbers []int + expectedRepo o.Option[ghrepo.Interface] + expectedErr bool }{ { - name: "multiple repos", - args: args{ - selectors: []string{"1", "https://github.com/OWNER/OTHERREPO/issues/2"}, - baseRepoFn: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - }, - httpStub: func(r *httpmock.Registry) { - r.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "hasIssuesEnabled": true, - "issue":{"number":1} - }}}`)) - r.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "hasIssuesEnabled": true, - "issue":{"number":2} - }}}`)) - }, - wantErr: true, - wantErrMsg: "multiple issues must be in same repo", + behavior: "when given issue numbers, returns them with no repo", + args: []string{"1", "2"}, + expectedIssueNumbers: []int{1, 2}, + expectedRepo: o.None[ghrepo.Interface](), }, { - name: "multiple issues", - args: args{ - selectors: []string{"1", "2"}, - baseRepoFn: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, + behavior: "when given # prefixed issue numbers, returns them with no repo", + args: []string{"#1", "#2"}, + expectedIssueNumbers: []int{1, 2}, + expectedRepo: o.None[ghrepo.Interface](), + }, + { + behavior: "when given URLs, returns them with the repo", + args: []string{ + "https://github.com/OWNER/REPO/issues/1", + "https://github.com/OWNER/REPO/issues/2", }, + expectedIssueNumbers: []int{1, 2}, + expectedRepo: o.Some(ghrepo.New("OWNER", "REPO")), + }, + { + behavior: "when given URLs in different repos, errors", + args: []string{ + "https://github.com/OWNER/REPO/issues/1", + "https://github.com/OWNER/OTHERREPO/issues/2", + }, + expectedErr: true, + }, + { + behavior: "when given an unparseable argument, errors", + args: []string{"://"}, + expectedErr: true, + }, + { + behavior: "when given a URL that isn't an issue or PR url, errors", + args: []string{"https://github.com"}, + expectedErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.behavior, func(t *testing.T) { + issueNumbers, repo, err := ParseIssuesFromArgs(tc.args) + + if tc.expectedErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedIssueNumbers, issueNumbers) + assert.Equal(t, tc.expectedRepo, repo) + }) + } + +} + +func TestFindIssuesOrPRs(t *testing.T) { + tests := []struct { + name string + issueNumbers []int + baseRepo ghrepo.Interface + httpStub func(*httpmock.Registry) + wantIssueNumbers []int + wantErr bool + }{ + { + name: "multiple issues", + issueNumbers: []int{1, 2}, + baseRepo: ghrepo.New("OWNER", "REPO"), httpStub: func(r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query IssueByNumber\b`), @@ -275,43 +309,48 @@ func TestIssuesFromArgsWithFields(t *testing.T) { "issue":{"number":2} }}}`)) }, - wantIssues: []int{1, 2}, - wantRepo: "https://github.com/OWNER/REPO", + wantIssueNumbers: []int{1, 2}, + }, + { + name: "any find error results in total error", + issueNumbers: []int{1, 2}, + baseRepo: ghrepo.New("OWNER", "REPO"), + httpStub: func(r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "hasIssuesEnabled": true, + "issue":{"number":1} + }}}`)) + r.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StatusStringResponse(500, "internal server error")) + }, + wantErr: true, }, } for _, tt := range tests { - if !tt.wantErr && len(tt.args.selectors) != len(tt.wantIssues) { - t.Fatal("number of selectors and issues not equal") - } t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} if tt.httpStub != nil { tt.httpStub(reg) } httpClient := &http.Client{Transport: reg} - issues, repo, err := IssuesFromArgsWithFields(httpClient, tt.args.baseRepoFn, tt.args.selectors, []string{"number"}) + issues, err := FindIssuesOrPRs(httpClient, tt.baseRepo, tt.issueNumbers, []string{"number"}) if (err != nil) != tt.wantErr { - t.Errorf("IssuesFromArgsWithFields() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("FindIssuesOrPRs() error = %v, wantErr %v", err, tt.wantErr) if issues == nil { return } } if tt.wantErr { - assert.Error(t, err) - assert.ErrorContains(t, err, tt.wantErrMsg) + require.Error(t, err) return } - assert.NoError(t, err) + + require.NoError(t, err) for i := range issues { - assert.Contains(t, tt.wantIssues, issues[i].Number) - } - if repo != nil { - repoURL := ghrepo.GenerateRepoURL(repo, "") - if repoURL != tt.wantRepo { - t.Errorf("want repo %s, got %s", tt.wantRepo, repoURL) - } - } else if tt.wantRepo != "" { - t.Errorf("want repo %sw, got nil", tt.wantRepo) + assert.Contains(t, tt.wantIssueNumbers, issues[i].Number) } }) } From 8b615ec2e69f6dff869a89e0a931605018c23587 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 16 Apr 2025 15:40:58 +0200 Subject: [PATCH 025/249] Issue close early arg parsing --- pkg/cmd/issue/argparsetest/argparsetest.go | 129 +++++++++++++++++++++ pkg/cmd/issue/close/close.go | 29 +++-- pkg/cmd/issue/close/close_test.go | 67 +++++------ pkg/cmd/issue/shared/lookup.go | 10 +- 4 files changed, 187 insertions(+), 48 deletions(-) create mode 100644 pkg/cmd/issue/argparsetest/argparsetest.go diff --git a/pkg/cmd/issue/argparsetest/argparsetest.go b/pkg/cmd/issue/argparsetest/argparsetest.go new file mode 100644 index 000000000..1433e6104 --- /dev/null +++ b/pkg/cmd/issue/argparsetest/argparsetest.go @@ -0,0 +1,129 @@ +package argparsetest + +import ( + "bytes" + "reflect" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/google/shlex" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newCmdFunc represents the typical function signature we use for creating commands e.g. `NewCmdView`. +// +// It is generic over `T` as each command construction has their own Options type e.g. `ViewOptions` +type newCmdFunc[T any] func(f *cmdutil.Factory, runF func(*T) error) *cobra.Command + +func TestArgParsing[T any](t *testing.T, fn newCmdFunc[T]) { + tests := []struct { + name string + input string + expectedissueNumber int + expectedBaseRepo ghrepo.Interface + expectErr bool + }{ + { + name: "no argument", + input: "", + expectErr: true, + }, + { + name: "issue number argument", + input: "23 --repo owner/repo", + expectedissueNumber: 23, + expectedBaseRepo: ghrepo.New("owner", "repo"), + }, + { + name: "argument is hash prefixed number", + // Escaping is required here to avoid what I think is shellex treating it as a comment. + input: "\\#23 --repo owner/repo", + expectedissueNumber: 23, + expectedBaseRepo: ghrepo.New("owner", "repo"), + }, + { + name: "argument is a URL", + input: "https://github.com/cli/cli/issues/23", + expectedissueNumber: 23, + expectedBaseRepo: ghrepo.New("cli", "cli"), + }, + { + name: "argument cannot be parsed to an issue", + input: "unparseable", + expectErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + + var gotOpts T + cmd := fn(f, func(opts *T) error { + gotOpts = *opts + return nil + }) + + cmdutil.EnableRepoOverride(cmd, f) + + // TODO: remember why we do this + cmd.Flags().BoolP("help", "x", false, "") + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + + if tt.expectErr { + require.Error(t, err) + return + } else { + require.NoError(t, err) + } + + actualIssueNumber := issueNumberFromOpts(t, gotOpts) + assert.Equal(t, tt.expectedissueNumber, actualIssueNumber) + + actualBaseRepo := baseRepoFromOpts(t, gotOpts) + assert.True( + t, + ghrepo.IsSame(tt.expectedBaseRepo, actualBaseRepo), + "expected base repo %+v, got %+v", tt.expectedBaseRepo, actualBaseRepo, + ) + }) + } +} + +func issueNumberFromOpts(t *testing.T, v any) int { + rv := reflect.ValueOf(v) + field := rv.FieldByName("IssueNumber") + if !field.IsValid() || field.Kind() != reflect.Int { + t.Fatalf("Type %T does not have IssueNumber int field", v) + } + return int(field.Int()) +} + +func baseRepoFromOpts(t *testing.T, v any) ghrepo.Interface { + rv := reflect.ValueOf(v) + field := rv.FieldByName("BaseRepo") + // check whether the field is valid and of type func() (ghrepo.Interface, error) + if !field.IsValid() || field.Kind() != reflect.Func { + t.Fatalf("Type %T does not have BaseRepo func field", v) + } + // call the function and check the return value + results := field.Call([]reflect.Value{}) + if len(results) != 2 { + t.Fatalf("%T.BaseRepo() does not return two values", v) + } + if !results[1].IsNil() { + t.Fatalf("%T.BaseRepo() returned an error: %v", v, results[1].Interface()) + } + return results[0].Interface().(ghrepo.Interface) +} diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go index 9197abff6..21fe45dd6 100644 --- a/pkg/cmd/issue/close/close.go +++ b/pkg/cmd/issue/close/close.go @@ -21,7 +21,7 @@ type CloseOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - SelectorArg string + IssueNumber int Comment string Reason string @@ -39,13 +39,23 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm Short: "Close issue", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo - - if len(args) > 0 { - opts.SelectorArg = args[0] + issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0]) + if err != nil { + return err } + // If the args provided the base repo then use that directly. + if baseRepo, present := baseRepo.Value(); present { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return baseRepo, nil + } + } else { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + } + + opts.IssueNumber = issueNumber + if runF != nil { return runF(opts) } @@ -67,7 +77,12 @@ func closeRun(opts *CloseOptions) error { return err } - issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "state"}) + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number", "title", "state"}) if err != nil { return err } diff --git a/pkg/cmd/issue/close/close_test.go b/pkg/cmd/issue/close/close_test.go index 4d50e56b2..04c39cd8d 100644 --- a/pkg/cmd/issue/close/close_test.go +++ b/pkg/cmd/issue/close/close_test.go @@ -7,46 +7,32 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/issue/argparsetest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewCmdClose(t *testing.T) { + // Test shared parsing of issue number / URL. + argparsetest.TestArgParsing(t, NewCmdClose) + tests := []struct { - name string - input string - output CloseOptions - wantErr bool - errMsg string + name string + input string + output CloseOptions + expectedBaseRepo ghrepo.Interface + wantErr bool + errMsg string }{ - { - name: "no argument", - input: "", - wantErr: true, - errMsg: "accepts 1 arg(s), received 0", - }, - { - name: "issue number", - input: "123", - output: CloseOptions{ - SelectorArg: "123", - }, - }, - { - name: "issue url", - input: "https://github.com/cli/cli/3", - output: CloseOptions{ - SelectorArg: "https://github.com/cli/cli/3", - }, - }, { name: "comment", input: "123 --comment 'closing comment'", output: CloseOptions{ - SelectorArg: "123", + IssueNumber: 123, Comment: "closing comment", }, }, @@ -54,7 +40,7 @@ func TestNewCmdClose(t *testing.T) { name: "reason", input: "123 --reason 'not planned'", output: CloseOptions{ - SelectorArg: "123", + IssueNumber: 123, Reason: "not planned", }, }, @@ -79,15 +65,24 @@ func TestNewCmdClose(t *testing.T) { _, err = cmd.ExecuteC() if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) assert.Equal(t, tt.errMsg, err.Error()) return } - assert.NoError(t, err) - assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg) + require.NoError(t, err) + assert.Equal(t, tt.output.IssueNumber, gotOpts.IssueNumber) assert.Equal(t, tt.output.Comment, gotOpts.Comment) assert.Equal(t, tt.output.Reason, gotOpts.Reason) + if tt.expectedBaseRepo != nil { + baseRepo, err := gotOpts.BaseRepo() + require.NoError(t, err) + require.True( + t, + ghrepo.IsSame(tt.expectedBaseRepo, baseRepo), + "expected base repo %+v, got %+v", tt.expectedBaseRepo, baseRepo, + ) + } }) } } @@ -104,7 +99,7 @@ func TestCloseRun(t *testing.T) { { name: "close issue by number", opts: &CloseOptions{ - SelectorArg: "13", + IssueNumber: 13, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -128,7 +123,7 @@ func TestCloseRun(t *testing.T) { { name: "close issue with comment", opts: &CloseOptions{ - SelectorArg: "13", + IssueNumber: 13, Comment: "closing comment", }, httpStubs: func(reg *httpmock.Registry) { @@ -164,7 +159,7 @@ func TestCloseRun(t *testing.T) { { name: "close issue with reason", opts: &CloseOptions{ - SelectorArg: "13", + IssueNumber: 13, Reason: "not planned", Detector: &fd.EnabledDetectorMock{}, }, @@ -192,7 +187,7 @@ func TestCloseRun(t *testing.T) { { name: "close issue with reason when reason is not supported", opts: &CloseOptions{ - SelectorArg: "13", + IssueNumber: 13, Reason: "not planned", Detector: &fd.DisabledDetectorMock{}, }, @@ -219,7 +214,7 @@ func TestCloseRun(t *testing.T) { { name: "issue already closed", opts: &CloseOptions{ - SelectorArg: "13", + IssueNumber: 13, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -236,7 +231,7 @@ func TestCloseRun(t *testing.T) { { name: "issues disabled", opts: &CloseOptions{ - SelectorArg: "13", + IssueNumber: 13, }, httpStubs: func(reg *httpmock.Registry) { reg.Register( diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index bff6018d9..370c1c3c6 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -24,7 +24,7 @@ func ParseIssuesFromArgs(args []string) ([]int, o.Option[ghrepo.Interface], erro for i, arg := range args { // For each argument, parse the issue number and an optional repo - issueNumber, issueRepo, err := parseIssueFromArg(arg) + issueNumber, issueRepo, err := ParseIssueFromArg(arg) if err != nil { return nil, o.None[ghrepo.Interface](), err } @@ -53,7 +53,7 @@ func ParseIssuesFromArgs(args []string) ([]int, o.Option[ghrepo.Interface], erro return issueNumbers, repo, nil } -func parseIssueFromArg(arg string) (int, o.Option[ghrepo.Interface], error) { +func ParseIssueFromArg(arg string) (int, o.Option[ghrepo.Interface], error) { issueLocator := tryParseIssueFromURL(arg) if issueLocator, present := issueLocator.Value(); present { return issueLocator.issueNumber, o.Some(issueLocator.repo), nil @@ -111,7 +111,7 @@ func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.I } } - issue, err := findIssueOrPR(httpClient, baseRepo, issueNumber, fields) + issue, err := FindIssueOrPR(httpClient, baseRepo, issueNumber, fields) return issue, baseRepo, err } @@ -165,7 +165,7 @@ func FindIssuesOrPRs(httpClient *http.Client, repo ghrepo.Interface, issueNumber for _, num := range issueNumbers { issueNumber := num g.Go(func() error { - issue, err := findIssueOrPR(httpClient, repo, issueNumber, fields) + issue, err := FindIssueOrPR(httpClient, repo, issueNumber, fields) if err != nil { return err } @@ -190,7 +190,7 @@ func FindIssuesOrPRs(httpClient *http.Client, repo ghrepo.Interface, issueNumber return issues, nil } -func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.Issue, error) { +func FindIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.Issue, error) { fieldSet := set.NewStringSet() fieldSet.AddValues(fields) if fieldSet.Contains("stateReason") { From 6129b26f9f95e24294f5b344e35157db8de980ea Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 16 Apr 2025 15:51:38 +0200 Subject: [PATCH 026/249] Issue delete early arg parsing --- pkg/cmd/issue/delete/delete.go | 29 ++++++++++++++++++++++------- pkg/cmd/issue/delete/delete_test.go | 5 +++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/issue/delete/delete.go b/pkg/cmd/issue/delete/delete.go index fb41f288e..269ef7081 100644 --- a/pkg/cmd/issue/delete/delete.go +++ b/pkg/cmd/issue/delete/delete.go @@ -21,7 +21,7 @@ type DeleteOptions struct { BaseRepo func() (ghrepo.Interface, error) Prompter iprompter - SelectorArg string + IssueNumber int Confirmed bool } @@ -42,13 +42,23 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co Short: "Delete issue", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo - - if len(args) > 0 { - opts.SelectorArg = args[0] + issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0]) + if err != nil { + return err } + // If the args provided the base repo then use that directly. + if baseRepo, present := baseRepo.Value(); present { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return baseRepo, nil + } + } else { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + } + + opts.IssueNumber = issueNumber + if runF != nil { return runF(opts) } @@ -71,7 +81,12 @@ func deleteRun(opts *DeleteOptions) error { return err } - issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title"}) + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number", "title"}) if err != nil { return err } diff --git a/pkg/cmd/issue/delete/delete_test.go b/pkg/cmd/issue/delete/delete_test.go index bd83c826f..64522b1d3 100644 --- a/pkg/cmd/issue/delete/delete_test.go +++ b/pkg/cmd/issue/delete/delete_test.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmd/issue/argparsetest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -20,6 +21,10 @@ import ( "github.com/stretchr/testify/assert" ) +func TestNewCmdDelete(t *testing.T) { + argparsetest.TestArgParsing(t, NewCmdDelete) +} + func runCommand(rt http.RoundTripper, pm *prompter.MockPrompter, isTTY bool, cli string) (*test.CmdOut, error) { ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(isTTY) From 7744e0564fce8e4e197831a1313db8afc8133da8 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 16 Apr 2025 15:51:46 +0200 Subject: [PATCH 027/249] Issue develop early arg parsing --- pkg/cmd/issue/develop/develop.go | 45 +++++--- pkg/cmd/issue/develop/develop_test.go | 156 +++++++++++--------------- 2 files changed, 96 insertions(+), 105 deletions(-) diff --git a/pkg/cmd/issue/develop/develop.go b/pkg/cmd/issue/develop/develop.go index 1536800f0..19c9b5fa9 100644 --- a/pkg/cmd/issue/develop/develop.go +++ b/pkg/cmd/issue/develop/develop.go @@ -24,12 +24,12 @@ type DevelopOptions struct { BaseRepo func() (ghrepo.Interface, error) Remotes func() (context.Remotes, error) - IssueSelector string - Name string - BranchRepo string - BaseBranch string - Checkout bool - List bool + IssueNumber int + Name string + BranchRepo string + BaseBranch string + Checkout bool + List bool } func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra.Command { @@ -89,9 +89,23 @@ func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra. return nil }, RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo - opts.IssueSelector = args[0] + issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0]) + if err != nil { + return err + } + + // If the args provided the base repo then use that directly. + if baseRepo, present := baseRepo.Value(); present { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return baseRepo, nil + } + } else { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + } + + opts.IssueNumber = issueNumber + if err := cmdutil.MutuallyExclusive("specify only one of `--list` or `--branch-repo`", opts.List, opts.BranchRepo != ""); err != nil { return err } @@ -131,8 +145,13 @@ func developRun(opts *DevelopOptions) error { return err } + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + opts.IO.StartProgressIndicator() - issue, issueRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.IssueSelector, []string{"id", "number"}) + issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number"}) opts.IO.StopProgressIndicator() if err != nil { return err @@ -141,16 +160,16 @@ func developRun(opts *DevelopOptions) error { apiClient := api.NewClientFromHTTP(httpClient) opts.IO.StartProgressIndicator() - err = api.CheckLinkedBranchFeature(apiClient, issueRepo.RepoHost()) + err = api.CheckLinkedBranchFeature(apiClient, baseRepo.RepoHost()) opts.IO.StopProgressIndicator() if err != nil { return err } if opts.List { - return developRunList(opts, apiClient, issueRepo, issue) + return developRunList(opts, apiClient, baseRepo, issue) } - return developRunCreate(opts, apiClient, issueRepo, issue) + return developRunCreate(opts, apiClient, baseRepo, issue) } func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghrepo.Interface, issue *api.Issue) error { diff --git a/pkg/cmd/issue/develop/develop_test.go b/pkg/cmd/issue/develop/develop_test.go index 831f03fc3..2485c8cc4 100644 --- a/pkg/cmd/issue/develop/develop_test.go +++ b/pkg/cmd/issue/develop/develop_test.go @@ -11,89 +11,74 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" + "github.com/cli/cli/v2/pkg/cmd/issue/argparsetest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewCmdDevelop(t *testing.T) { + // Test shared parsing of issue number / URL. + argparsetest.TestArgParsing(t, NewCmdDevelop) + tests := []struct { - name string - input string - output DevelopOptions - wantStdout string - wantStderr string - wantErr bool - errMsg string + name string + input string + output DevelopOptions + expectedBaseRepo ghrepo.Interface + wantStdout string + wantStderr string + wantErr bool + errMsg string }{ - { - name: "no argument", - input: "", - output: DevelopOptions{}, - wantErr: true, - errMsg: "issue number or url is required", - }, - { - name: "issue number", - input: "1", - output: DevelopOptions{ - IssueSelector: "1", - }, - }, - { - name: "issue url", - input: "https://github.com/cli/cli/issues/1", - output: DevelopOptions{ - IssueSelector: "https://github.com/cli/cli/issues/1", - }, - }, { name: "branch-repo flag", input: "1 --branch-repo owner/repo", output: DevelopOptions{ - IssueSelector: "1", - BranchRepo: "owner/repo", + IssueNumber: 1, + BranchRepo: "owner/repo", }, }, { name: "base flag", input: "1 --base feature", output: DevelopOptions{ - IssueSelector: "1", - BaseBranch: "feature", + IssueNumber: 1, + BaseBranch: "feature", }, }, { name: "checkout flag", input: "1 --checkout", output: DevelopOptions{ - IssueSelector: "1", - Checkout: true, + IssueNumber: 1, + Checkout: true, }, }, { name: "list flag", input: "1 --list", output: DevelopOptions{ - IssueSelector: "1", - List: true, + IssueNumber: 1, + List: true, }, }, { name: "name flag", input: "1 --name feature", output: DevelopOptions{ - IssueSelector: "1", - Name: "feature", + IssueNumber: 1, + Name: "feature", }, }, { name: "issue-repo flag", input: "1 --issue-repo cli/cli", output: DevelopOptions{ - IssueSelector: "1", + IssueNumber: 1, }, wantStdout: "Flag --issue-repo has been deprecated, use `--repo` instead\n", }, @@ -143,18 +128,27 @@ func TestNewCmdDevelop(t *testing.T) { _, err = cmd.ExecuteC() if tt.wantErr { - assert.EqualError(t, err, tt.errMsg) + require.EqualError(t, err, tt.errMsg) return } - assert.NoError(t, err) - assert.Equal(t, tt.output.IssueSelector, gotOpts.IssueSelector) + require.NoError(t, err) + assert.Equal(t, tt.output.IssueNumber, gotOpts.IssueNumber) assert.Equal(t, tt.output.Name, gotOpts.Name) assert.Equal(t, tt.output.BaseBranch, gotOpts.BaseBranch) assert.Equal(t, tt.output.Checkout, gotOpts.Checkout) assert.Equal(t, tt.output.List, gotOpts.List) assert.Equal(t, tt.wantStdout, stdOut.String()) assert.Equal(t, tt.wantStderr, stdErr.String()) + if tt.expectedBaseRepo != nil { + baseRepo, err := gotOpts.BaseRepo() + require.NoError(t, err) + require.True( + t, + ghrepo.IsSame(tt.expectedBaseRepo, baseRepo), + "expected base repo %+v, got %+v", tt.expectedBaseRepo, baseRepo, + ) + } }) } } @@ -178,8 +172,8 @@ func TestDevelopRun(t *testing.T) { { name: "returns an error when the feature is not supported by the API", opts: &DevelopOptions{ - IssueSelector: "42", - List: true, + IssueNumber: 42, + List: true, }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( @@ -196,8 +190,8 @@ func TestDevelopRun(t *testing.T) { { name: "list branches for an issue", opts: &DevelopOptions{ - IssueSelector: "42", - List: true, + IssueNumber: 42, + List: true, }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( @@ -223,8 +217,8 @@ func TestDevelopRun(t *testing.T) { { name: "list branches for an issue in tty", opts: &DevelopOptions{ - IssueSelector: "42", - List: true, + IssueNumber: 42, + List: true, }, tty: true, httpStubs: func(reg *httpmock.Registry, t *testing.T) { @@ -255,37 +249,10 @@ func TestDevelopRun(t *testing.T) { bar https://github.com/OWNER/OTHER-REPO/tree/bar `), }, - { - name: "list branches for an issue providing an issue url", - opts: &DevelopOptions{ - IssueSelector: "https://github.com/cli/cli/issues/42", - List: true, - }, - httpStubs: func(reg *httpmock.Registry, t *testing.T) { - reg.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{"hasIssuesEnabled":true,"issue":{"id":"SOMEID","number":42}}}}`), - ) - reg.Register( - httpmock.GraphQL(`query LinkedBranchFeature\b`), - httpmock.StringResponse(featureEnabledPayload), - ) - reg.Register( - httpmock.GraphQL(`query ListLinkedBranches\b`), - httpmock.GraphQLQuery(` - {"data":{"repository":{"issue":{"linkedBranches":{"nodes":[{"ref":{"name":"foo","repository":{"url":"https://github.com/OWNER/REPO"}}},{"ref":{"name":"bar","repository":{"url":"https://github.com/OWNER/OTHER-REPO"}}}]}}}}} - `, func(query string, inputs map[string]interface{}) { - assert.Equal(t, float64(42), inputs["number"]) - assert.Equal(t, "cli", inputs["owner"]) - assert.Equal(t, "cli", inputs["name"]) - })) - }, - expectedOut: "foo\thttps://github.com/OWNER/REPO/tree/foo\nbar\thttps://github.com/OWNER/OTHER-REPO/tree/bar\n", - }, { name: "develop new branch", opts: &DevelopOptions{ - IssueSelector: "123", + IssueNumber: 123, }, remotes: map[string]string{ "origin": "OWNER/REPO", @@ -321,8 +288,8 @@ func TestDevelopRun(t *testing.T) { { name: "develop new branch in different repo than issue", opts: &DevelopOptions{ - IssueSelector: "123", - BranchRepo: "OWNER2/REPO", + IssueNumber: 123, + BranchRepo: "OWNER2/REPO", }, remotes: map[string]string{ "origin": "OWNER2/REPO", @@ -367,9 +334,9 @@ func TestDevelopRun(t *testing.T) { { name: "develop new branch with name and base specified", opts: &DevelopOptions{ - Name: "my-branch", - BaseBranch: "main", - IssueSelector: "123", + Name: "my-branch", + BaseBranch: "main", + IssueNumber: 123, }, remotes: map[string]string{ "origin": "OWNER/REPO", @@ -406,7 +373,10 @@ func TestDevelopRun(t *testing.T) { { name: "develop new branch outside of local git repo", opts: &DevelopOptions{ - IssueSelector: "https://github.com/cli/cli/issues/123", + IssueNumber: 123, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("cli", "cli"), nil + }, }, httpStubs: func(reg *httpmock.Registry, t *testing.T) { reg.Register( @@ -436,9 +406,9 @@ func TestDevelopRun(t *testing.T) { { name: "develop new branch with checkout when local branch exists", opts: &DevelopOptions{ - Name: "my-branch", - IssueSelector: "123", - Checkout: true, + Name: "my-branch", + IssueNumber: 123, + Checkout: true, }, remotes: map[string]string{ "origin": "OWNER/REPO", @@ -478,9 +448,9 @@ func TestDevelopRun(t *testing.T) { { name: "develop new branch with checkout when local branch does not exist", opts: &DevelopOptions{ - Name: "my-branch", - IssueSelector: "123", - Checkout: true, + Name: "my-branch", + IssueNumber: 123, + Checkout: true, }, remotes: map[string]string{ "origin": "OWNER/REPO", @@ -519,8 +489,8 @@ func TestDevelopRun(t *testing.T) { { name: "develop with base branch which does not exist", opts: &DevelopOptions{ - IssueSelector: "123", - BaseBranch: "does-not-exist-branch", + IssueNumber: 123, + BaseBranch: "does-not-exist-branch", }, remotes: map[string]string{ "origin": "OWNER/REPO", @@ -561,8 +531,10 @@ func TestDevelopRun(t *testing.T) { ios.SetStderrTTY(tt.tty) opts.IO = ios - opts.BaseRepo = func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil + if opts.BaseRepo == nil { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } } opts.Remotes = func() (context.Remotes, error) { From 60f248458c78548626ecedb7506f24e82ed6a3b2 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 16 Apr 2025 15:57:34 +0200 Subject: [PATCH 028/249] Issue lock early arg parsing --- pkg/cmd/issue/lock/lock.go | 47 ++++++++++++---- pkg/cmd/issue/lock/lock_test.go | 98 +++++++++++++++++++++++++-------- 2 files changed, 111 insertions(+), 34 deletions(-) diff --git a/pkg/cmd/issue/lock/lock.go b/pkg/cmd/issue/lock/lock.go index 4e0dac058..2f332d21d 100644 --- a/pkg/cmd/issue/lock/lock.go +++ b/pkg/cmd/issue/lock/lock.go @@ -19,6 +19,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/issue/shared" issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -99,20 +100,33 @@ type LockOptions struct { ParentCmd string Reason string - SelectorArg string + IssueNumber int Interactive bool } -func (opts *LockOptions) setCommonOptions(f *cmdutil.Factory, args []string) { +func (opts *LockOptions) setCommonOptions(f *cmdutil.Factory, args []string) error { opts.IO = f.IOStreams opts.HttpClient = f.HttpClient opts.Config = f.Config - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0]) + if err != nil { + return err + } - opts.SelectorArg = args[0] + // If the args provided the base repo then use that directly. + if baseRepo, present := baseRepo.Value(); present { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return baseRepo, nil + } + } else { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + } + opts.IssueNumber = issueNumber + + return nil } func NewCmdLock(f *cmdutil.Factory, parentName string, runF func(string, *LockOptions) error) *cobra.Command { @@ -129,7 +143,9 @@ func NewCmdLock(f *cmdutil.Factory, parentName string, runF func(string, *LockOp Short: short, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.setCommonOptions(f, args) + if err := opts.setCommonOptions(f, args); err != nil { + return err + } reasonProvided := cmd.Flags().Changed("reason") if reasonProvided { @@ -172,7 +188,9 @@ func NewCmdUnlock(f *cmdutil.Factory, parentName string, runF func(string, *Lock Short: short, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.setCommonOptions(f, args) + if err := opts.setCommonOptions(f, args); err != nil { + return err + } if runF != nil { return runF(Unlock, opts) @@ -214,13 +232,18 @@ func lockRun(state string, opts *LockOptions) error { return err } - issuePr, baseRepo, err := issueShared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, fields()) - - parent := alias[opts.ParentCmd] - + baseRepo, err := opts.BaseRepo() if err != nil { return err - } else if parent.Typename != issuePr.Typename { + } + + issuePr, err := issueShared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, fields()) + if err != nil { + return err + } + + parent := alias[opts.ParentCmd] + if parent.Typename != issuePr.Typename { currentType := alias[parent.Typename] correctType := alias[issuePr.Typename] diff --git a/pkg/cmd/issue/lock/lock_test.go b/pkg/cmd/issue/lock/lock_test.go index f6dcb746d..1ca320f35 100644 --- a/pkg/cmd/issue/lock/lock_test.go +++ b/pkg/cmd/issue/lock/lock_test.go @@ -30,7 +30,7 @@ func Test_NewCmdLock(t *testing.T) { args: "--reason off_topic 451", want: LockOptions{ Reason: "off_topic", - SelectorArg: "451", + IssueNumber: 451, }, }, { @@ -41,9 +41,36 @@ func Test_NewCmdLock(t *testing.T) { name: "no flags", args: "451", want: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, }, }, + { + name: "issue number argument", + args: "451 --repo owner/repo", + want: LockOptions{ + IssueNumber: 451, + }, + }, + { + name: "argument is hash prefixed number", + // Escaping is required here to avoid what I think is shellex treating it as a comment. + args: "\\#451 --repo owner/repo", + want: LockOptions{ + IssueNumber: 451, + }, + }, + { + name: "argument is a URL", + args: "https://github.com/cli/cli/issues/451", + want: LockOptions{ + IssueNumber: 451, + }, + }, + { + name: "argument cannot be parsed to an issue", + args: "unparseable", + wantErr: "invalid issue format: \"unparseable\"", + }, { name: "bad reason", args: "--reason bad 451", @@ -60,7 +87,7 @@ func Test_NewCmdLock(t *testing.T) { args: "451", tty: true, want: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, Interactive: true, }, }, @@ -99,7 +126,7 @@ func Test_NewCmdLock(t *testing.T) { } assert.Equal(t, tt.want.Reason, opts.Reason) - assert.Equal(t, tt.want.SelectorArg, opts.SelectorArg) + assert.Equal(t, tt.want.IssueNumber, opts.IssueNumber) assert.Equal(t, tt.want.Interactive, opts.Interactive) }) } @@ -121,9 +148,36 @@ func Test_NewCmdUnlock(t *testing.T) { name: "no flags", args: "451", want: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, }, }, + { + name: "issue number argument", + args: "451 --repo owner/repo", + want: LockOptions{ + IssueNumber: 451, + }, + }, + { + name: "argument is hash prefixed number", + // Escaping is required here to avoid what I think is shellex treating it as a comment. + args: "\\#451 --repo owner/repo", + want: LockOptions{ + IssueNumber: 451, + }, + }, + { + name: "argument is a URL", + args: "https://github.com/cli/cli/issues/451", + want: LockOptions{ + IssueNumber: 451, + }, + }, + { + name: "argument cannot be parsed to an issue", + args: "unparseable", + wantErr: "invalid issue format: \"unparseable\"", + }, } for _, tt := range cases { @@ -158,7 +212,7 @@ func Test_NewCmdUnlock(t *testing.T) { assert.NoError(t, err) } - assert.Equal(t, tt.want.SelectorArg, opts.SelectorArg) + assert.Equal(t, tt.want.IssueNumber, opts.IssueNumber) }) } } @@ -179,7 +233,7 @@ func Test_runLock(t *testing.T) { name: "lock issue nontty", state: Lock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "issue", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { @@ -203,7 +257,7 @@ func Test_runLock(t *testing.T) { tty: true, opts: LockOptions{ Interactive: true, - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "issue", }, state: Lock, @@ -241,7 +295,7 @@ func Test_runLock(t *testing.T) { tty: true, state: Lock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "issue", Reason: "off_topic", }, @@ -268,7 +322,7 @@ func Test_runLock(t *testing.T) { tty: true, state: Unlock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "issue", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { @@ -294,7 +348,7 @@ func Test_runLock(t *testing.T) { name: "unlock issue nontty", state: Unlock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "issue", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { @@ -319,7 +373,7 @@ func Test_runLock(t *testing.T) { name: "lock issue with explicit reason nontty", state: Lock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "issue", Reason: "off_topic", }, @@ -344,7 +398,7 @@ func Test_runLock(t *testing.T) { name: "relock issue tty", state: Lock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "issue", Reason: "off_topic", }, @@ -388,7 +442,7 @@ func Test_runLock(t *testing.T) { name: "relock issue nontty", state: Lock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "issue", Reason: "off_topic", }, @@ -409,7 +463,7 @@ func Test_runLock(t *testing.T) { name: "lock pr nontty", state: Lock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "pr", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { @@ -433,7 +487,7 @@ func Test_runLock(t *testing.T) { tty: true, opts: LockOptions{ Interactive: true, - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "pr", }, state: Lock, @@ -469,7 +523,7 @@ func Test_runLock(t *testing.T) { tty: true, state: Lock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "pr", Reason: "off_topic", }, @@ -495,7 +549,7 @@ func Test_runLock(t *testing.T) { name: "lock pr with explicit nontty", state: Lock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "pr", Reason: "off_topic", }, @@ -520,7 +574,7 @@ func Test_runLock(t *testing.T) { name: "unlock pr tty", state: Unlock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "pr", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { @@ -546,7 +600,7 @@ func Test_runLock(t *testing.T) { tty: true, state: Unlock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "pr", }, httpStubs: func(t *testing.T, reg *httpmock.Registry) { @@ -572,7 +626,7 @@ func Test_runLock(t *testing.T) { name: "relock pr tty", state: Lock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "pr", Reason: "off_topic", }, @@ -616,7 +670,7 @@ func Test_runLock(t *testing.T) { name: "relock pr nontty", state: Lock, opts: LockOptions{ - SelectorArg: "451", + IssueNumber: 451, ParentCmd: "pr", Reason: "off_topic", }, From 96699329fc7ccac9f0443c0c17f64e4ff422eaba Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 16 Apr 2025 15:57:39 +0200 Subject: [PATCH 029/249] Issue pin early arg parsing --- pkg/cmd/issue/pin/pin.go | 27 ++++++++++++-- pkg/cmd/issue/pin/pin_test.go | 69 +++-------------------------------- 2 files changed, 28 insertions(+), 68 deletions(-) diff --git a/pkg/cmd/issue/pin/pin.go b/pkg/cmd/issue/pin/pin.go index dfb11a881..290bec507 100644 --- a/pkg/cmd/issue/pin/pin.go +++ b/pkg/cmd/issue/pin/pin.go @@ -20,7 +20,7 @@ type PinOptions struct { Config func() (gh.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - SelectorArg string + IssueNumber int } func NewCmdPin(f *cmdutil.Factory, runF func(*PinOptions) error) *cobra.Command { @@ -51,8 +51,22 @@ func NewCmdPin(f *cmdutil.Factory, runF func(*PinOptions) error) *cobra.Command `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.BaseRepo = f.BaseRepo - opts.SelectorArg = args[0] + issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0]) + if err != nil { + return err + } + + // If the args provided the base repo then use that directly. + if baseRepo, present := baseRepo.Value(); present { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return baseRepo, nil + } + } else { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + } + + opts.IssueNumber = issueNumber if runF != nil { return runF(opts) @@ -73,7 +87,12 @@ func pinRun(opts *PinOptions) error { return err } - issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "isPinned"}) + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number", "title", "isPinned"}) if err != nil { return err } diff --git a/pkg/cmd/issue/pin/pin_test.go b/pkg/cmd/issue/pin/pin_test.go index d4979a30d..67b767b32 100644 --- a/pkg/cmd/issue/pin/pin_test.go +++ b/pkg/cmd/issue/pin/pin_test.go @@ -1,80 +1,21 @@ package pin import ( - "bytes" "net/http" "testing" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/cmd/issue/argparsetest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/google/shlex" "github.com/stretchr/testify/assert" ) func TestNewCmdPin(t *testing.T) { - tests := []struct { - name string - input string - output PinOptions - wantErr bool - errMsg string - }{ - { - name: "no argument", - input: "", - wantErr: true, - errMsg: "accepts 1 arg(s), received 0", - }, - { - name: "issue number", - input: "6", - output: PinOptions{ - SelectorArg: "6", - }, - }, - { - name: "issue url", - input: "https://github.com/cli/cli/6", - output: PinOptions{ - SelectorArg: "https://github.com/cli/cli/6", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - f := &cmdutil.Factory{ - IOStreams: ios, - } - argv, err := shlex.Split(tt.input) - assert.NoError(t, err) - var gotOpts *PinOptions - cmd := NewCmdPin(f, func(opts *PinOptions) error { - gotOpts = opts - return nil - }) - cmd.SetArgs(argv) - cmd.SetIn(&bytes.Buffer{}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - - _, err = cmd.ExecuteC() - if tt.wantErr { - assert.Error(t, err) - assert.Equal(t, tt.errMsg, err.Error()) - return - } - - assert.NoError(t, err) - assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg) - }) - } + // Test shared parsing of issue number / URL. + argparsetest.TestArgParsing(t, NewCmdPin) } func TestPinRun(t *testing.T) { @@ -89,7 +30,7 @@ func TestPinRun(t *testing.T) { { name: "pin issue", tty: true, - opts: &PinOptions{SelectorArg: "20"}, + opts: &PinOptions{IssueNumber: 20}, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), @@ -113,7 +54,7 @@ func TestPinRun(t *testing.T) { { name: "issue already pinned", tty: true, - opts: &PinOptions{SelectorArg: "20"}, + opts: &PinOptions{IssueNumber: 20}, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), From 5c67c1944b29c0b367248583dac1262f2fe3045f Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 16 Apr 2025 15:59:00 +0200 Subject: [PATCH 030/249] Issue reopen early arg parsing --- pkg/cmd/issue/reopen/reopen.go | 29 ++++++++++++++++++++++------- pkg/cmd/issue/reopen/reopen_test.go | 6 ++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/issue/reopen/reopen.go b/pkg/cmd/issue/reopen/reopen.go index 92f18a7d9..f01a8eafc 100644 --- a/pkg/cmd/issue/reopen/reopen.go +++ b/pkg/cmd/issue/reopen/reopen.go @@ -21,7 +21,7 @@ type ReopenOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - SelectorArg string + IssueNumber int Comment string } @@ -37,13 +37,23 @@ func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Co Short: "Reopen issue", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo - - if len(args) > 0 { - opts.SelectorArg = args[0] + issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0]) + if err != nil { + return err } + // If the args provided the base repo then use that directly. + if baseRepo, present := baseRepo.Value(); present { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return baseRepo, nil + } + } else { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + } + + opts.IssueNumber = issueNumber + if runF != nil { return runF(opts) } @@ -64,7 +74,12 @@ func reopenRun(opts *ReopenOptions) error { return err } - issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "state"}) + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number", "title", "state"}) if err != nil { return err } diff --git a/pkg/cmd/issue/reopen/reopen_test.go b/pkg/cmd/issue/reopen/reopen_test.go index 4b8b33ee1..f7c8cb95a 100644 --- a/pkg/cmd/issue/reopen/reopen_test.go +++ b/pkg/cmd/issue/reopen/reopen_test.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/issue/argparsetest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -18,6 +19,11 @@ import ( "github.com/stretchr/testify/assert" ) +func TestNewCmdReopen(t *testing.T) { + // Test shared parsing of issue number / URL. + argparsetest.TestArgParsing(t, NewCmdReopen) +} + func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(isTTY) From f55138c8964dbb5fcac2c9fa7c90eaf9e54481ae Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 16 Apr 2025 16:01:05 +0200 Subject: [PATCH 031/249] Issue transfer early arg parsing --- pkg/cmd/issue/transfer/transfer.go | 28 +++++++++-- pkg/cmd/issue/transfer/transfer_test.go | 64 +++++++++++++++++++++---- 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/issue/transfer/transfer.go b/pkg/cmd/issue/transfer/transfer.go index 140d02b91..a6dfb9b23 100644 --- a/pkg/cmd/issue/transfer/transfer.go +++ b/pkg/cmd/issue/transfer/transfer.go @@ -20,7 +20,7 @@ type TransferOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - IssueSelector string + IssueNumber int DestRepoSelector string } @@ -36,8 +36,23 @@ func NewCmdTransfer(f *cmdutil.Factory, runF func(*TransferOptions) error) *cobr Short: "Transfer issue to another repository", Args: cmdutil.ExactArgs(2, "issue and destination repository are required"), RunE: func(cmd *cobra.Command, args []string) error { - opts.BaseRepo = f.BaseRepo - opts.IssueSelector = args[0] + issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0]) + if err != nil { + return err + } + + // If the args provided the base repo then use that directly. + if baseRepo, present := baseRepo.Value(); present { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return baseRepo, nil + } + } else { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + } + + opts.IssueNumber = issueNumber + opts.DestRepoSelector = args[1] if runF != nil { @@ -57,7 +72,12 @@ func transferRun(opts *TransferOptions) error { return err } - issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.IssueSelector, []string{"id", "number"}) + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number"}) if err != nil { return err } diff --git a/pkg/cmd/issue/transfer/transfer_test.go b/pkg/cmd/issue/transfer/transfer_test.go index eed9c5d85..2b12db944 100644 --- a/pkg/cmd/issue/transfer/transfer_test.go +++ b/pkg/cmd/issue/transfer/transfer_test.go @@ -15,6 +15,7 @@ import ( "github.com/cli/cli/v2/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func runCommand(rt http.RoundTripper, cli string) (*test.CmdOut, error) { @@ -57,18 +58,49 @@ func runCommand(rt http.RoundTripper, cli string) (*test.CmdOut, error) { func TestNewCmdTransfer(t *testing.T) { tests := []struct { - name string - cli string - wants TransferOptions - wantErr string + name string + cli string + wants TransferOptions + wantBaseRepo ghrepo.Interface + wantErr bool }{ { - name: "issue name", - cli: "3252 OWNER/REPO", + name: "no argument", + cli: "", + wantErr: true, + }, + { + name: "issue number argument", + cli: "--repo cli/repo 23 OWNER/REPO", wants: TransferOptions{ - IssueSelector: "3252", + IssueNumber: 23, DestRepoSelector: "OWNER/REPO", }, + wantBaseRepo: ghrepo.New("cli", "repo"), + }, + { + name: "argument is hash prefixed number", + // Escaping is required here to avoid what I think is shellex treating it as a comment. + cli: "--repo cli/repo \\#23 OWNER/REPO", + wants: TransferOptions{ + IssueNumber: 23, + DestRepoSelector: "OWNER/REPO", + }, + wantBaseRepo: ghrepo.New("cli", "repo"), + }, + { + name: "argument is a URL", + cli: "https://github.com/cli/cli/issues/23 OWNER/REPO", + wants: TransferOptions{ + IssueNumber: 23, + DestRepoSelector: "OWNER/REPO", + }, + wantBaseRepo: ghrepo.New("cli", "cli"), + }, + { + name: "argument cannot be parsed to an issue", + cli: "unparseable OWNER/REPO", + wantErr: true, }, } @@ -84,15 +116,29 @@ func TestNewCmdTransfer(t *testing.T) { gotOpts = opts return nil }) + cmdutil.EnableRepoOverride(cmd, f) + cmd.SetArgs(argv) cmd.SetIn(&bytes.Buffer{}) cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) _, cErr := cmd.ExecuteC() - assert.NoError(t, cErr) - assert.Equal(t, tt.wants.IssueSelector, gotOpts.IssueSelector) + if tt.wantErr { + require.Error(t, cErr) + return + } + + require.NoError(t, cErr) + assert.Equal(t, tt.wants.IssueNumber, gotOpts.IssueNumber) assert.Equal(t, tt.wants.DestRepoSelector, gotOpts.DestRepoSelector) + actualBaseRepo, err := gotOpts.BaseRepo() + require.NoError(t, err) + assert.True( + t, + ghrepo.IsSame(tt.wantBaseRepo, actualBaseRepo), + "expected base repo %+v, got %+v", tt.wantBaseRepo, actualBaseRepo, + ) }) } } From 455c77add8213947e49ff9f0d81b29b1fa84d494 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 16 Apr 2025 16:04:07 +0200 Subject: [PATCH 032/249] Issue unpin early arg parsing --- pkg/cmd/issue/unpin/unpin.go | 36 ++++++++++++---- pkg/cmd/issue/unpin/unpin_test.go | 71 +++---------------------------- 2 files changed, 34 insertions(+), 73 deletions(-) diff --git a/pkg/cmd/issue/unpin/unpin.go b/pkg/cmd/issue/unpin/unpin.go index 3ac28d47c..ca22aa82e 100644 --- a/pkg/cmd/issue/unpin/unpin.go +++ b/pkg/cmd/issue/unpin/unpin.go @@ -16,11 +16,12 @@ import ( ) type UnpinOptions struct { - HttpClient func() (*http.Client, error) - Config func() (gh.Config, error) - IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) - SelectorArg string + HttpClient func() (*http.Client, error) + Config func() (gh.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + IssueNumber int } func NewCmdUnpin(f *cmdutil.Factory, runF func(*UnpinOptions) error) *cobra.Command { @@ -51,8 +52,22 @@ func NewCmdUnpin(f *cmdutil.Factory, runF func(*UnpinOptions) error) *cobra.Comm `), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - opts.BaseRepo = f.BaseRepo - opts.SelectorArg = args[0] + issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0]) + if err != nil { + return err + } + + // If the args provided the base repo then use that directly. + if baseRepo, present := baseRepo.Value(); present { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return baseRepo, nil + } + } else { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + } + + opts.IssueNumber = issueNumber if runF != nil { return runF(opts) @@ -73,7 +88,12 @@ func unpinRun(opts *UnpinOptions) error { return err } - issue, baseRepo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, []string{"id", "number", "title", "isPinned"}) + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + issue, err := shared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, []string{"id", "number", "title", "isPinned"}) if err != nil { return err } diff --git a/pkg/cmd/issue/unpin/unpin_test.go b/pkg/cmd/issue/unpin/unpin_test.go index 70a018d94..3cdf29a74 100644 --- a/pkg/cmd/issue/unpin/unpin_test.go +++ b/pkg/cmd/issue/unpin/unpin_test.go @@ -1,80 +1,21 @@ package unpin import ( - "bytes" "net/http" "testing" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/cmd/issue/argparsetest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" - "github.com/google/shlex" "github.com/stretchr/testify/assert" ) -func TestNewCmdPin(t *testing.T) { - tests := []struct { - name string - input string - output UnpinOptions - wantErr bool - errMsg string - }{ - { - name: "no argument", - input: "", - wantErr: true, - errMsg: "accepts 1 arg(s), received 0", - }, - { - name: "issue number", - input: "6", - output: UnpinOptions{ - SelectorArg: "6", - }, - }, - { - name: "issue url", - input: "https://github.com/cli/cli/6", - output: UnpinOptions{ - SelectorArg: "https://github.com/cli/cli/6", - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - f := &cmdutil.Factory{ - IOStreams: ios, - } - argv, err := shlex.Split(tt.input) - assert.NoError(t, err) - var gotOpts *UnpinOptions - cmd := NewCmdUnpin(f, func(opts *UnpinOptions) error { - gotOpts = opts - return nil - }) - cmd.SetArgs(argv) - cmd.SetIn(&bytes.Buffer{}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - - _, err = cmd.ExecuteC() - if tt.wantErr { - assert.Error(t, err) - assert.Equal(t, tt.errMsg, err.Error()) - return - } - - assert.NoError(t, err) - assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg) - }) - } +func TestNewCmdUnpin(t *testing.T) { + // Test shared parsing of issue number / URL. + argparsetest.TestArgParsing(t, NewCmdUnpin) } func TestUnpinRun(t *testing.T) { @@ -89,7 +30,7 @@ func TestUnpinRun(t *testing.T) { { name: "unpin issue", tty: true, - opts: &UnpinOptions{SelectorArg: "20"}, + opts: &UnpinOptions{IssueNumber: 20}, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), @@ -113,7 +54,7 @@ func TestUnpinRun(t *testing.T) { { name: "issue not pinned", tty: true, - opts: &UnpinOptions{SelectorArg: "20"}, + opts: &UnpinOptions{IssueNumber: 20}, httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueByNumber\b`), From e474acc2ddf8c9414ea9f45214b5864048f15e68 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 16 Apr 2025 16:09:45 +0200 Subject: [PATCH 033/249] Issue comment early arg parsing --- pkg/cmd/issue/comment/comment.go | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index 090b0748c..706ff791e 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -3,6 +3,7 @@ package comment import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/issue/shared" issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -37,15 +38,41 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { opts.RetrieveCommentable = func() (prShared.Commentable, ghrepo.Interface, error) { + // TODO wm: more testing + issueNumber, parsedBaseRepo, err := shared.ParseIssueFromArg(args[0]) + if err != nil { + return nil, nil, err + } + + // If the args provided the base repo then use that directly. + var baseRepo ghrepo.Interface + + if parsedBaseRepo, present := parsedBaseRepo.Value(); present { + baseRepo = parsedBaseRepo + } else { + // support `-R, --repo` override + baseRepo, err = f.BaseRepo() + if err != nil { + return nil, nil, err + } + } + httpClient, err := f.HttpClient() if err != nil { return nil, nil, err } + fields := []string{"id", "url"} if opts.EditLast { fields = append(fields, "comments") } - return issueShared.IssueFromArgWithFields(httpClient, f.BaseRepo, args[0], fields) + + issue, err := issueShared.FindIssueOrPR(httpClient, baseRepo, issueNumber, fields) + if err != nil { + return nil, nil, err + } + + return issue, baseRepo, nil } return prShared.CommentablePreRun(cmd, opts) }, From 81ecbd8e1d85639a13be0a283dd7b315a9da1037 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 16 Apr 2025 16:23:24 +0200 Subject: [PATCH 034/249] Issue view early arg parsing --- pkg/cmd/issue/view/view.go | 59 +++++++++++++++++++-------------- pkg/cmd/issue/view/view_test.go | 10 ++++-- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 8e3aa6040..f7838429b 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/issue/shared" issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -29,7 +30,7 @@ type ViewOptions struct { BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser - SelectorArg string + IssueNumber int WebMode bool Comments bool Exporter cmdutil.Exporter @@ -55,13 +56,23 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman `, "`"), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo - - if len(args) > 0 { - opts.SelectorArg = args[0] + issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0]) + if err != nil { + return err } + // If the args provided the base repo then use that directly. + if baseRepo, present := baseRepo.Value(); present { + opts.BaseRepo = func() (ghrepo.Interface, error) { + return baseRepo, nil + } + } else { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + } + + opts.IssueNumber = issueNumber + if runF != nil { return runF(opts) } @@ -87,6 +98,11 @@ func viewRun(opts *ViewOptions) error { return err } + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + lookupFields := set.NewStringSet() if opts.Exporter != nil { lookupFields.AddValues(opts.Exporter.Fields()) @@ -103,7 +119,18 @@ func viewRun(opts *ViewOptions) error { opts.IO.DetectTerminalTheme() opts.IO.StartProgressIndicator() - issue, baseRepo, err := findIssue(httpClient, opts.BaseRepo, opts.SelectorArg, lookupFields.ToSlice()) + lookupFields.Add("id") + + issue, err := issueShared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, lookupFields.ToSlice()) + if err != nil { + return err + } + + if lookupFields.Contains("comments") { + // FIXME: this re-fetches the comments connection even though the initial set of 100 were + // fetched in the previous request. + err = preloadIssueComments(httpClient, baseRepo, issue) + } opts.IO.StopProgressIndicator() if err != nil { var loadErr *issueShared.PartialLoadError @@ -143,24 +170,6 @@ func viewRun(opts *ViewOptions) error { return printRawIssuePreview(opts.IO.Out, issue) } -func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), selector string, fields []string) (*api.Issue, ghrepo.Interface, error) { - fieldSet := set.NewStringSet() - fieldSet.AddValues(fields) - fieldSet.Add("id") - - issue, repo, err := issueShared.IssueFromArgWithFields(client, baseRepoFn, selector, fieldSet.ToSlice()) - if err != nil { - return issue, repo, err - } - - if fieldSet.Contains("comments") { - // FIXME: this re-fetches the comments connection even though the initial set of 100 were - // fetched in the previous request. - err = preloadIssueComments(client, repo, issue) - } - return issue, repo, err -} - func printRawIssuePreview(out io.Writer, issue *api.Issue) error { assignees := issueAssigneeList(*issue) labels := issueLabelList(issue, nil) diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index e1798af9f..2dd963687 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -13,6 +13,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" + "github.com/cli/cli/v2/pkg/cmd/issue/argparsetest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -47,6 +48,11 @@ func TestJSONFields(t *testing.T) { }) } +func TestNewCmdView(t *testing.T) { + // Test shared parsing of issue number / URL. + argparsetest.TestArgParsing(t, NewCmdView) +} + func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(isTTY) @@ -116,7 +122,7 @@ func TestIssueView_web(t *testing.T) { return ghrepo.New("OWNER", "REPO"), nil }, WebMode: true, - SelectorArg: "123", + IssueNumber: 123, }) if err != nil { t.Errorf("error running command `issue view`: %v", err) @@ -273,7 +279,7 @@ func TestIssueView_tty_Preview(t *testing.T) { BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, - SelectorArg: "123", + IssueNumber: 123, } err := viewRun(&opts) From cfa90a2c8038a315681cb644d2a885a4924d6cbc Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 17 Apr 2025 15:25:04 +0200 Subject: [PATCH 035/249] Remove old issue lookup funcs --- pkg/cmd/issue/shared/lookup.go | 59 +------- pkg/cmd/issue/shared/lookup_test.go | 205 ---------------------------- 2 files changed, 2 insertions(+), 262 deletions(-) diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index 370c1c3c6..5c477363b 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -18,6 +18,8 @@ import ( "golang.org/x/sync/errgroup" ) +var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/(?:issues|pull)/(\d+)`) + func ParseIssuesFromArgs(args []string) ([]int, o.Option[ghrepo.Interface], error) { var repo o.Option[ghrepo.Interface] issueNumbers := make([]int, len(args)) @@ -96,63 +98,6 @@ func tryParseIssueFromURL(maybeURL string) o.Option[issueLocator] { }) } -// IssueFromArgWithFields loads an issue or pull request with the specified fields. If some of the fields -// could not be fetched by GraphQL, this returns a non-nil issue and a *PartialLoadError. -func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), arg string, fields []string) (*api.Issue, ghrepo.Interface, error) { - issueNumber, baseRepo, err := IssueNumberAndRepoFromArg(arg) - if err != nil { - return nil, nil, err - } - - if baseRepo == nil { - var err error - if baseRepo, err = baseRepoFn(); err != nil { - return nil, nil, err - } - } - - issue, err := FindIssueOrPR(httpClient, baseRepo, issueNumber, fields) - return issue, baseRepo, err -} - -var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/(?:issues|pull)/(\d+)`) - -func issueMetadataFromURL(s string) (int, ghrepo.Interface) { - u, err := url.Parse(s) - if err != nil { - return 0, nil - } - - if u.Scheme != "https" && u.Scheme != "http" { - return 0, nil - } - - m := issueURLRE.FindStringSubmatch(u.Path) - if m == nil { - return 0, nil - } - - repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname()) - issueNumber, _ := strconv.Atoi(m[3]) - return issueNumber, repo -} - -// Returns the issue number and repo if the issue URL is provided. -// If only the issue number is provided, returns the number and nil repo. -func IssueNumberAndRepoFromArg(arg string) (int, ghrepo.Interface, error) { - issueNumber, baseRepo := issueMetadataFromURL(arg) - - if issueNumber == 0 { - var err error - issueNumber, err = strconv.Atoi(strings.TrimPrefix(arg, "#")) - if err != nil { - return 0, nil, fmt.Errorf("invalid issue format: %q", arg) - } - } - - return issueNumber, baseRepo, nil -} - type PartialLoadError struct { error } diff --git a/pkg/cmd/issue/shared/lookup_test.go b/pkg/cmd/issue/shared/lookup_test.go index d9e8bc31a..f921ca49b 100644 --- a/pkg/cmd/issue/shared/lookup_test.go +++ b/pkg/cmd/issue/shared/lookup_test.go @@ -2,7 +2,6 @@ package shared import ( "net/http" - "strings" "testing" "github.com/cli/cli/v2/internal/ghrepo" @@ -12,210 +11,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestIssueFromArgWithFields(t *testing.T) { - type args struct { - baseRepoFn func() (ghrepo.Interface, error) - selector string - } - tests := []struct { - name string - args args - httpStub func(*httpmock.Registry) - wantIssue int - wantRepo string - wantProjects string - wantErr bool - }{ - { - name: "number argument", - args: args{ - selector: "13", - baseRepoFn: func() (ghrepo.Interface, error) { - return ghrepo.FromFullName("OWNER/REPO") - }, - }, - httpStub: func(r *httpmock.Registry) { - r.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "hasIssuesEnabled": true, - "issue":{"number":13} - }}}`)) - }, - wantIssue: 13, - wantRepo: "https://github.com/OWNER/REPO", - }, - { - name: "number with hash argument", - args: args{ - selector: "#13", - baseRepoFn: func() (ghrepo.Interface, error) { - return ghrepo.FromFullName("OWNER/REPO") - }, - }, - httpStub: func(r *httpmock.Registry) { - r.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "hasIssuesEnabled": true, - "issue":{"number":13} - }}}`)) - }, - wantIssue: 13, - wantRepo: "https://github.com/OWNER/REPO", - }, - { - name: "URL argument", - args: args{ - selector: "https://example.org/OWNER/REPO/issues/13#comment-123", - baseRepoFn: nil, - }, - httpStub: func(r *httpmock.Registry) { - r.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "hasIssuesEnabled": true, - "issue":{"number":13} - }}}`)) - }, - wantIssue: 13, - wantRepo: "https://example.org/OWNER/REPO", - }, - { - name: "PR URL argument", - args: args{ - selector: "https://example.org/OWNER/REPO/pull/13#comment-123", - baseRepoFn: nil, - }, - httpStub: func(r *httpmock.Registry) { - r.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(`{"data":{"repository":{ - "hasIssuesEnabled": true, - "issue":{"number":13} - }}}`)) - }, - wantIssue: 13, - wantRepo: "https://example.org/OWNER/REPO", - }, - { - name: "project cards permission issue", - args: args{ - selector: "https://example.org/OWNER/REPO/issues/13", - baseRepoFn: nil, - }, - httpStub: func(r *httpmock.Registry) { - r.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(` - { - "data": { - "repository": { - "hasIssuesEnabled": true, - "issue": { - "number": 13, - "projectCards": { - "nodes": [ - null, - { - "project": {"name": "myproject"}, - "column": {"name": "To Do"} - }, - null, - { - "project": {"name": "other project"}, - "column": null - } - ] - } - } - } - }, - "errors": [ - { - "type": "FORBIDDEN", - "message": "Resource not accessible by integration", - "path": ["repository", "issue", "projectCards", "nodes", 0] - }, - { - "type": "FORBIDDEN", - "message": "Resource not accessible by integration", - "path": ["repository", "issue", "projectCards", "nodes", 2] - } - ] - }`)) - }, - wantErr: true, - wantIssue: 13, - wantProjects: "myproject, other project", - wantRepo: "https://example.org/OWNER/REPO", - }, - { - name: "projects permission issue", - args: args{ - selector: "https://example.org/OWNER/REPO/issues/13", - baseRepoFn: nil, - }, - httpStub: func(r *httpmock.Registry) { - r.Register( - httpmock.GraphQL(`query IssueByNumber\b`), - httpmock.StringResponse(` - { - "data": { - "repository": { - "hasIssuesEnabled": true, - "issue": { - "number": 13, - "projectCards": { - "nodes": null, - "totalCount": 0 - } - } - } - }, - "errors": [ - { - "type": "FORBIDDEN", - "message": "Resource not accessible by integration", - "path": ["repository", "issue", "projectCards", "nodes"] - } - ] - }`)) - }, - wantErr: true, - wantIssue: 13, - wantProjects: "", - wantRepo: "https://example.org/OWNER/REPO", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - reg := &httpmock.Registry{} - if tt.httpStub != nil { - tt.httpStub(reg) - } - httpClient := &http.Client{Transport: reg} - issue, repo, err := IssueFromArgWithFields(httpClient, tt.args.baseRepoFn, tt.args.selector, []string{"number"}) - if (err != nil) != tt.wantErr { - t.Errorf("IssueFromArgWithFields() error = %v, wantErr %v", err, tt.wantErr) - if issue == nil { - return - } - } - if issue.Number != tt.wantIssue { - t.Errorf("want issue #%d, got #%d", tt.wantIssue, issue.Number) - } - if gotProjects := strings.Join(issue.ProjectCards.ProjectNames(), ", "); gotProjects != tt.wantProjects { - t.Errorf("want projects %q, got %q", tt.wantProjects, gotProjects) - } - repoURL := ghrepo.GenerateRepoURL(repo, "") - if repoURL != tt.wantRepo { - t.Errorf("want repo %s, got %s", tt.wantRepo, repoURL) - } - }) - } -} - func TestParseIssuesFromArgs(t *testing.T) { tests := []struct { behavior string From 55d3b1eaa4e27acad346707ab91c9eb0e4a32a71 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 17 Apr 2025 15:33:44 +0200 Subject: [PATCH 036/249] Document TestArgParsing --- pkg/cmd/issue/argparsetest/argparsetest.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/cmd/issue/argparsetest/argparsetest.go b/pkg/cmd/issue/argparsetest/argparsetest.go index 1433e6104..5ae1ada8d 100644 --- a/pkg/cmd/issue/argparsetest/argparsetest.go +++ b/pkg/cmd/issue/argparsetest/argparsetest.go @@ -18,6 +18,14 @@ import ( // It is generic over `T` as each command construction has their own Options type e.g. `ViewOptions` type newCmdFunc[T any] func(f *cmdutil.Factory, runF func(*T) error) *cobra.Command +// TestArgParsing is a test helper that verifies that issue commands correctly parse the `{issue number | url}` +// positional arg into an issue number and base repo. +// +// Looking through the existing tests, I noticed that the coverage was pretty smattered. +// Since nearly all issue commands only accept a single positional argument, we are able to reuse this test helper. +// Commands with no further flags or args can use this solely. +// Commands with extra flags use this and further table tests. +// Commands with extra required positional arguments (like `transfer`) cannot use this. They duplicate these cases inline. func TestArgParsing[T any](t *testing.T, fn newCmdFunc[T]) { tests := []struct { name string From fff0e70259c41d482b6415620b33431c45142c6d Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 16 Apr 2025 16:40:29 +0200 Subject: [PATCH 037/249] Provide better HTTP Exclude failure messages --- pkg/httpmock/registry.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/pkg/httpmock/registry.go b/pkg/httpmock/registry.go index 51aa5a898..b7c5a117d 100644 --- a/pkg/httpmock/registry.go +++ b/pkg/httpmock/registry.go @@ -7,8 +7,6 @@ import ( "strings" "sync" "testing" - - "github.com/stretchr/testify/assert" ) // Replace http.Client transport layer with registry so all requests get @@ -32,10 +30,21 @@ func (r *Registry) Register(m Matcher, resp Responder) { } func (r *Registry) Exclude(t *testing.T, m Matcher) { + registrationStack := string(debug.Stack()) + excludedStub := &Stub{ Matcher: m, Responder: func(req *http.Request) (*http.Response, error) { - assert.FailNowf(t, "Exclude error", "API called when excluded: %v", req.URL) + callStack := string(debug.Stack()) + + var errMsg strings.Builder + errMsg.WriteString("HTTP call was made when it should have been excluded:\n") + errMsg.WriteString(fmt.Sprintf("Request URL: %s\n", req.URL)) + errMsg.WriteString(fmt.Sprintf("Was excluded by: %s\n", registrationStack)) + errMsg.WriteString(fmt.Sprintf("Was called from: %s\n", callStack)) + + t.Error(errMsg.String()) + t.FailNow() return nil, nil }, exclude: true, From 5ec2160bc6cf15c6aa71e4b66e7560d5d82a8e56 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 16 Apr 2025 16:46:37 +0200 Subject: [PATCH 038/249] Avoid requesting projectCards for issue view --- internal/featuredetection/detector_mock.go | 10 +++ .../featuredetection/feature_detection.go | 12 ++++ .../feature_detection_test.go | 18 ++++++ internal/gh/projects.go | 23 +++++++ pkg/cmd/issue/view/view.go | 17 ++++- pkg/cmd/issue/view/view_test.go | 64 +++++++++++++++++++ 6 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 internal/gh/projects.go diff --git a/internal/featuredetection/detector_mock.go b/internal/featuredetection/detector_mock.go index 6f36dd3fc..6f760f209 100644 --- a/internal/featuredetection/detector_mock.go +++ b/internal/featuredetection/detector_mock.go @@ -1,5 +1,7 @@ package featuredetection +import "github.com/cli/cli/v2/internal/gh" + type DisabledDetectorMock struct{} func (md *DisabledDetectorMock) IssueFeatures() (IssueFeatures, error) { @@ -14,6 +16,10 @@ func (md *DisabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error) return RepositoryFeatures{}, nil } +func (md *DisabledDetectorMock) ProjectsV1() gh.ProjectsV1Support { + return gh.ProjectsV1Unsupported +} + type EnabledDetectorMock struct{} func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) { @@ -27,3 +33,7 @@ func (md *EnabledDetectorMock) PullRequestFeatures() (PullRequestFeatures, error func (md *EnabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error) { return allRepositoryFeatures, nil } + +func (md *EnabledDetectorMock) ProjectsV1() gh.ProjectsV1Support { + return gh.ProjectsV1Supported +} diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index a9bbe25f8..fba317f58 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" "golang.org/x/sync/errgroup" ghauth "github.com/cli/go-gh/v2/pkg/auth" @@ -13,6 +14,7 @@ type Detector interface { IssueFeatures() (IssueFeatures, error) PullRequestFeatures() (PullRequestFeatures, error) RepositoryFeatures() (RepositoryFeatures, error) + ProjectsV1() gh.ProjectsV1Support } type IssueFeatures struct { @@ -199,3 +201,13 @@ func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) { return features, nil } + +func (d *detector) ProjectsV1() gh.ProjectsV1Support { + // Currently, projects v1 support is entirely dependent on the host. As this is deprecated in GHES, + // we will do feature detection on whether the GHES version has support. + if ghauth.IsEnterprise(d.host) { + return gh.ProjectsV1Supported + } + + return gh.ProjectsV1Unsupported +} diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index 8af091c3f..f1152da2c 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -5,8 +5,10 @@ import ( "testing" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIssueFeatures(t *testing.T) { @@ -366,3 +368,19 @@ func TestRepositoryFeatures(t *testing.T) { }) } } + +func TestProjectV1Support(t *testing.T) { + t.Parallel() + + t.Run("when the host is enterprise, project v1 is supported", func(t *testing.T) { + detector := detector{host: "my.ghes.com"} + isProjectV1Supported := detector.ProjectsV1() + require.Equal(t, gh.ProjectsV1Supported, isProjectV1Supported) + }) + + t.Run("when the host is not enterprise, project v1 is not supported", func(t *testing.T) { + detector := detector{host: "github.com"} + isProjectV1Supported := detector.ProjectsV1() + require.Equal(t, gh.ProjectsV1Unsupported, isProjectV1Supported) + }) +} diff --git a/internal/gh/projects.go b/internal/gh/projects.go new file mode 100644 index 000000000..34acf8d7c --- /dev/null +++ b/internal/gh/projects.go @@ -0,0 +1,23 @@ +package gh + +// ProjectsV1Support provides type safety and readability around whether or not Projects v1 is supported +// by the targeted host. +// +// It is a sealed type to ensure that consumers must use the exported ProjectsV1Supported and ProjectsV1Unsupported +// variables to get an instance of the type. +type ProjectsV1Support interface { + sealed() +} + +type projectsV1Supported struct{} + +func (projectsV1Supported) sealed() {} + +type projectsV1Unsupported struct{} + +func (projectsV1Unsupported) sealed() {} + +var ( + ProjectsV1Supported ProjectsV1Support = projectsV1Supported{} + ProjectsV1Unsupported ProjectsV1Support = projectsV1Unsupported{} +) diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index f7838429b..a9e25513b 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -12,6 +12,8 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" + fd "github.com/cli/cli/v2/internal/featuredetection" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/issue/shared" @@ -29,6 +31,7 @@ type ViewOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser + Detector fd.Detector IssueNumber int WebMode bool @@ -89,7 +92,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman var defaultFields = []string{ "number", "url", "state", "createdAt", "title", "body", "author", "milestone", - "assignees", "labels", "projectCards", "reactionGroups", "lastComment", "stateReason", + "assignees", "labels", "reactionGroups", "lastComment", "stateReason", } func viewRun(opts *ViewOptions) error { @@ -114,6 +117,18 @@ func viewRun(opts *ViewOptions) error { lookupFields.Add("comments") lookupFields.Remove("lastComment") } + + // TODO projectsV1Deprecation + // Remove this section as we should no longer add projectCards + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + } + + projectsV1Support := opts.Detector.ProjectsV1() + if projectsV1Support == gh.ProjectsV1Supported { + lookupFields.Add("projectCards") + } } opts.IO.DetectTerminalTheme() diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 2dd963687..391a288fb 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" @@ -496,3 +497,66 @@ func TestIssueView_nontty_Comments(t *testing.T) { }) } } + +// TODO projectsV1Deprecation +// Remove this test. +func TestProjectsV1Deprecation(t *testing.T) { + t.Run("when projects v1 is supported, is included in query", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`projectCards`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = viewRun(&ViewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + + Detector: &fd.EnabledDetectorMock{}, + IssueNumber: 123, + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) + + t.Run("when projects v1 is not supported, is not included in query", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Exclude(t, httpmock.GraphQL(`projectCards`)) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we're not really interested in it. + _ = viewRun(&ViewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + + Detector: &fd.DisabledDetectorMock{}, + IssueNumber: 123, + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) +} From a1f5d42283071d42f6725518f1cd4f51955c8e0d Mon Sep 17 00:00:00 2001 From: Barak Amar Date: Thu, 17 Apr 2025 17:13:28 +0300 Subject: [PATCH 039/249] Update the test code to align with latest changes --- pkg/cmd/pr/shared/finder_test.go | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 09c2bf7a7..66fb900eb 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -83,19 +83,6 @@ func TestFind(t *testing.T) { wantPR: 13, wantRepo: "https://github.com/ORIGINOWNER/REPO", }, - { - name: "PR number 0 is invalid", - args: args{ - selector: "0", - fields: []string{"id", "number"}, - baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil), - branchFn: func() (string, error) { - return "blueberries", nil - }, - branchConfig: stubBranchConfig(git.BranchConfig{}, nil), - }, - wantErr: true, - }, { name: "number argument with base branch", args: args{ @@ -178,6 +165,25 @@ func TestFind(t *testing.T) { wantPR: 13, wantRepo: "https://github.com/ORIGINOWNER/REPO", }, + { + name: "pr number zero", + args: args{ + selector: "0", + fields: []string{"number"}, + baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil), + branchFn: func() (string, error) { + return "blueberries", nil + }, + gitConfigClient: stubGitConfigClient{ + readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), + pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), + remotePushDefaultFn: stubRemotePushDefault("", nil), + }, + }, + httpStub: nil, + wantPR: 0, + wantRepo: "https://github.com/ORIGINOWNER/REPO", + }, { name: "number with hash argument", args: args{ From c8dd61d837d8e948cbeddab53b995d3b653b95ea Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 17 Apr 2025 17:06:35 +0200 Subject: [PATCH 040/249] Feature detect v1 projects on non-interactive issue create --- api/queries_repo.go | 90 +++++++++++++++++++++++------ api/queries_repo_test.go | 3 +- pkg/cmd/issue/create/create.go | 27 ++++++--- pkg/cmd/issue/create/create_test.go | 78 +++++++++++++++++++++++++ pkg/cmd/pr/create/create.go | 16 +++-- pkg/cmd/pr/shared/editable.go | 3 +- pkg/cmd/pr/shared/params.go | 13 +++-- pkg/cmd/pr/shared/survey.go | 5 +- pkg/cmd/pr/shared/survey_test.go | 65 ++++++++++++++++++++- 9 files changed, 258 insertions(+), 42 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 53e6d879a..7c33825b0 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -863,7 +863,8 @@ type RepoMetadataInput struct { Assignees bool Reviewers bool Labels bool - Projects bool + ProjectsV1 bool + ProjectsV2 bool Milestones bool } @@ -882,6 +883,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput return err }) } + if input.Reviewers { g.Go(func() error { teams, err := OrganizationTeams(client, repo) @@ -894,6 +896,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput return nil }) } + if input.Reviewers { g.Go(func() error { login, err := CurrentLoginName(client, repo.RepoHost()) @@ -904,6 +907,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput return err }) } + if input.Labels { g.Go(func() error { labels, err := RepoLabels(client, repo) @@ -914,13 +918,23 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput return err }) } - if input.Projects { + + if input.ProjectsV1 { g.Go(func() error { var err error - result.Projects, result.ProjectsV2, err = relevantProjects(client, repo) + result.Projects, err = v1Projects(client, repo) return err }) } + + if input.ProjectsV2 { + g.Go(func() error { + var err error + result.ProjectsV2, err = v2Projects(client, repo) + return err + }) + } + if input.Milestones { g.Go(func() error { milestones, err := RepoMilestones(client, repo, "open") @@ -943,7 +957,8 @@ type RepoResolveInput struct { Assignees []string Reviewers []string Labels []string - Projects []string + ProjectsV1 bool + ProjectsV2 bool Milestones []string } @@ -970,7 +985,8 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes // there is no way to look up projects nor milestones by name, so preload them all mi := RepoMetadataInput{ - Projects: len(input.Projects) > 0, + ProjectsV1: input.ProjectsV1, + ProjectsV2: input.ProjectsV2, Milestones: len(input.Milestones) > 0, } result, err := RepoMetadata(client, repo, mi) @@ -1245,18 +1261,12 @@ func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []s return ProjectsToPaths(projects, projectsV2, projectNames) } -// RelevantProjects retrieves set of Projects and ProjectsV2 relevant to given repository: +// v1Projects retrieves set of RepoProjects relevant to given repository: // - Projects for repository // - Projects for repository organization, if it belongs to one -// - ProjectsV2 owned by current user -// - ProjectsV2 linked to repository -// - ProjectsV2 owned by repository organization, if it belongs to one -func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []ProjectV2, error) { +func v1Projects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) { var repoProjects []RepoProject var orgProjects []RepoProject - var userProjectsV2 []ProjectV2 - var repoProjectsV2 []ProjectV2 - var orgProjectsV2 []ProjectV2 g, _ := errgroup.WithContext(context.Background()) @@ -1268,6 +1278,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P } return err }) + g.Go(func() error { var err error orgProjects, err = OrganizationProjects(client, repo) @@ -1277,6 +1288,29 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P } return nil }) + + if err := g.Wait(); err != nil { + return nil, err + } + + projects := make([]RepoProject, 0, len(repoProjects)+len(orgProjects)) + projects = append(projects, repoProjects...) + projects = append(projects, orgProjects...) + + return projects, nil +} + +// v2Projects retrieves set of ProjectV2 relevant to given repository: +// - ProjectsV2 owned by current user +// - ProjectsV2 linked to repository +// - ProjectsV2 owned by repository organization, if it belongs to one +func v2Projects(client *Client, repo ghrepo.Interface) ([]ProjectV2, error) { + var userProjectsV2 []ProjectV2 + var repoProjectsV2 []ProjectV2 + var orgProjectsV2 []ProjectV2 + + g, _ := errgroup.WithContext(context.Background()) + g.Go(func() error { var err error userProjectsV2, err = CurrentUserProjectsV2(client, repo.RepoHost()) @@ -1286,6 +1320,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P } return nil }) + g.Go(func() error { var err error repoProjectsV2, err = RepoProjectsV2(client, repo) @@ -1295,6 +1330,7 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P } return nil }) + g.Go(func() error { var err error orgProjectsV2, err = OrganizationProjectsV2(client, repo) @@ -1308,13 +1344,9 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P }) if err := g.Wait(); err != nil { - return nil, nil, err + return nil, err } - projects := make([]RepoProject, 0, len(repoProjects)+len(orgProjects)) - projects = append(projects, repoProjects...) - projects = append(projects, orgProjects...) - // ProjectV2 might appear across multiple queries so use a map to keep them deduplicated. m := make(map[string]ProjectV2, len(userProjectsV2)+len(repoProjectsV2)+len(orgProjectsV2)) for _, p := range userProjectsV2 { @@ -1331,7 +1363,27 @@ func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []P projectsV2 = append(projectsV2, p) } - return projects, projectsV2, nil + return projectsV2, nil +} + +// relevantProjects retrieves set of Project or ProjectV2 relevant to given repository: +// - Projects for repository +// - Projects for repository organization, if it belongs to one +// - ProjectsV2 owned by current user +// - ProjectsV2 linked to repository +// - ProjectsV2 owned by repository organization, if it belongs to one +func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []ProjectV2, error) { + v1Projects, err := v1Projects(client, repo) + if err != nil { + return nil, nil, err + } + + v2Projects, err := v2Projects(client, repo) + if err != nil { + return nil, nil, err + } + + return v1Projects, v2Projects, nil } func CreateRepoTransformToV4(apiClient *Client, hostname string, method string, path string, body io.Reader) (*Repository, error) { diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 13aee459a..5f5f55b67 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -44,7 +44,8 @@ func Test_RepoMetadata(t *testing.T) { Assignees: true, Reviewers: true, Labels: true, - Projects: true, + ProjectsV1: true, + ProjectsV2: true, Milestones: true, } diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 2e3e0de51..0976fd94f 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -4,10 +4,12 @@ import ( "errors" "fmt" "net/http" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" @@ -24,6 +26,7 @@ type CreateOptions struct { BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser Prompter prShared.Prompt + Detector fd.Detector TitledEditSurvey func(string, string) (string, string, error) RootDirOverride string @@ -46,11 +49,12 @@ type CreateOptions struct { func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { opts := &CreateOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - Config: f.Config, - Browser: f.Browser, - Prompter: f.Prompter, + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Browser: f.Browser, + Prompter: f.Prompter, + TitledEditSurvey: prShared.TitledEditSurvey(&prShared.UserEditor{Config: f.Config, IO: f.IOStreams}), } @@ -146,6 +150,15 @@ func createRun(opts *CreateOptions) (err error) { return } + // TODO projectsV1Deprecation + // Remove this section as we should no longer need to detect + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + } + + projectsV1Support := opts.Detector.ProjectsV1() + isTerminal := opts.IO.IsStdoutTTY() var milestones []string @@ -279,7 +292,7 @@ func createRun(opts *CreateOptions) (err error) { Repo: baseRepo, State: &tb, } - err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb) + err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support) if err != nil { return } @@ -335,7 +348,7 @@ func createRun(opts *CreateOptions) (err error) { params["issueTemplate"] = templateNameForSubmit } - err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb) + err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb, projectsV1Support) if err != nil { return } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 8e49700a0..d8c1c5d92 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -14,6 +14,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" @@ -521,6 +522,7 @@ func runCommandWithRootDirOverridden(rt http.RoundTripper, isTTY bool, cli strin cmd := NewCmdCreate(factory, func(opts *CreateOptions) error { opts.RootDirOverride = rootDir + opts.Detector = &fd.EnabledDetectorMock{} return createRun(opts) }) @@ -1026,3 +1028,79 @@ func TestIssueCreate_projectsV2(t *testing.T) { assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String()) } + +// TODO projectsV1Deprecation +// Remove this test. +func TestProjectsV1Deprecation(t *testing.T) { + + t.Run("non-interactive submission", func(t *testing.T) { + t.Run("when projects v1 is supported, queries for it", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "main") + reg.Register( + // ( is required to avoid matching projectsV2 + httpmock.GraphQL(`projects\(`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = createRun(&CreateOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + + Detector: &fd.EnabledDetectorMock{}, + Title: "Test Title", + Body: "Test Body", + // Required to force a lookup of projects + Projects: []string{"Project"}, + }) + + // Verify that our request contained projects + reg.Verify(t) + }) + + t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "main") + // ( is required to avoid matching projectsV2 + reg.Exclude(t, httpmock.GraphQL(`projects\(`)) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we're not really interested in it. + _ = createRun(&CreateOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + + Detector: &fd.DisabledDetectorMock{}, + Title: "Test Title", + Body: "Test Body", + // Required to force a lookup of projects + Projects: []string{"Project"}, + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) + }) +} diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index eda7a3ce7..27c929dc4 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -440,7 +440,8 @@ func createRun(opts *CreateOptions) error { if err != nil { return err } - return submitPR(*opts, *ctx, *state) + // TODO wm: revisit project support + return submitPR(*opts, *ctx, *state, gh.ProjectsV1Supported) } if opts.RecoverFile != "" { @@ -536,7 +537,8 @@ func createRun(opts *CreateOptions) error { Repo: ctx.PRRefs.BaseRepo(), State: state, } - err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state) + // TODO wm: revisit project support + err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, gh.ProjectsV1Supported) if err != nil { return err } @@ -565,11 +567,13 @@ func createRun(opts *CreateOptions) error { if action == shared.SubmitDraftAction { state.Draft = true - return submitPR(*opts, *ctx, *state) + // TODO wm: revisit project support + return submitPR(*opts, *ctx, *state, gh.ProjectsV1Supported) } if action == shared.SubmitAction { - return submitPR(*opts, *ctx, *state) + // TODO wm: revisit project support + return submitPR(*opts, *ctx, *state, gh.ProjectsV1Supported) } err = errors.New("expected to cancel, preview, or submit") @@ -966,7 +970,7 @@ func getRemotes(opts *CreateOptions) (ghContext.Remotes, error) { return remotes, nil } -func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataState) error { +func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataState, projectV1Support gh.ProjectsV1Support) error { client := ctx.Client params := map[string]interface{}{ @@ -982,7 +986,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, projectV1Support) if err != nil { return err } diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index cec3bfe8c..0bebb999a 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -381,7 +381,8 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable) Reviewers: editable.Reviewers.Edited, Assignees: editable.Assignees.Edited, Labels: editable.Labels.Edited, - Projects: editable.Projects.Edited, + ProjectsV1: editable.Projects.Edited, + ProjectsV2: editable.Projects.Edited, Milestones: editable.Milestone.Edited, } metadata, err := api.RepoMetadata(client, repo, input) diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 128c51068..9688a2aca 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/search" "github.com/google/shlex" @@ -56,7 +57,7 @@ func ValidURL(urlStr string) bool { // Ensure that tb.MetadataResult object exists and contains enough pre-fetched API data to be able // to resolve all object listed in tb to GraphQL IDs. -func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetadataState) error { +func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetadataState, projectV1Support gh.ProjectsV1Support) error { resolveInput := api.RepoResolveInput{} if len(tb.Assignees) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.AssignableUsers) == 0) { @@ -72,7 +73,11 @@ func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetada } if len(tb.Projects) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Projects) == 0) { - resolveInput.Projects = tb.Projects + if projectV1Support == gh.ProjectsV1Supported { + resolveInput.ProjectsV1 = true + } + + resolveInput.ProjectsV2 = true } if len(tb.Milestones) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Milestones) == 0) { @@ -93,12 +98,12 @@ func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetada return nil } -func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *IssueMetadataState) error { +func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *IssueMetadataState, projectV1Support gh.ProjectsV1Support) error { if !tb.HasMetadata() { return nil } - if err := fillMetadata(client, baseRepo, tb); err != nil { + if err := fillMetadata(client, baseRepo, tb, projectV1Support); err != nil { return err } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index ce38535d9..23079a391 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -151,7 +151,7 @@ type RepoMetadataFetcher interface { RepoMetadataFetch(api.RepoMetadataInput) (*api.RepoMetadataResult, error) } -func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState) error { +func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState, projectsV1Support gh.ProjectsV1Support) error { isChosen := func(m string) bool { for _, c := range state.Metadata { if m == c { @@ -181,7 +181,8 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface Reviewers: isChosen("Reviewers"), Assignees: isChosen("Assignees"), Labels: isChosen("Labels"), - Projects: isChosen("Projects"), + ProjectsV1: isChosen("Projects") && projectsV1Support == gh.ProjectsV1Supported, + ProjectsV2: isChosen("Projects"), Milestones: isChosen("Milestone"), } metadataResult, err := fetcher.RepoMetadataFetch(metadataInput) diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index d74696460..c094b7556 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -1,13 +1,16 @@ package shared import ( + "errors" "testing" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) type metadataFetcher struct { @@ -68,7 +71,7 @@ func TestMetadataSurvey_selectAll(t *testing.T) { Assignees: []string{"hubot"}, Type: PRMetadata, } - err := MetadataSurvey(pm, ios, repo, fetcher, state) + err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) @@ -113,7 +116,8 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { state := &IssueMetadataState{ Assignees: []string{"hubot"}, } - err := MetadataSurvey(pm, ios, repo, fetcher, state) + + err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported) assert.NoError(t, err) assert.Equal(t, "", stdout.String()) @@ -124,6 +128,63 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { assert.Equal(t, []string{"The road to 1.0"}, state.Projects) } +// TODO projectsV1Deprecation +// Remove this test and projectsV1MetadataFetcherSpy +func TestMetadataSurveyProjectV1Deprecation(t *testing.T) { + t.Run("when projectsV1 is supported, requests projectsV1", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + repo := ghrepo.New("OWNER", "REPO") + + fetcher := &projectsV1MetadataFetcherSpy{} + pm := prompter.NewMockPrompter(t) + pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, options []string) ([]int, error) { + i, err := prompter.IndexFor(options, "Projects") + require.NoError(t, err) + return []int{i}, nil + }) + pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring"}, func(_ string, _, _ []string) ([]int, error) { + return []int{0}, nil + }) + + err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Supported) + require.ErrorContains(t, err, "expected test error") + + require.True(t, fetcher.projectsV1Requested, "expected projectsV1 to be requested") + }) + + t.Run("when projectsV1 is supported, does not request projectsV1", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + repo := ghrepo.New("OWNER", "REPO") + + fetcher := &projectsV1MetadataFetcherSpy{} + pm := prompter.NewMockPrompter(t) + pm.RegisterMultiSelect("What would you like to add?", []string{}, []string{"Assignees", "Labels", "Projects", "Milestone"}, func(_ string, _, options []string) ([]int, error) { + i, err := prompter.IndexFor(options, "Projects") + require.NoError(t, err) + return []int{i}, nil + }) + pm.RegisterMultiSelect("Projects", []string{}, []string{"Huge Refactoring"}, func(_ string, _, _ []string) ([]int, error) { + return []int{0}, nil + }) + + err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Unsupported) + require.ErrorContains(t, err, "expected test error") + + require.False(t, fetcher.projectsV1Requested, "expected projectsV1 not to be requested") + }) +} + +type projectsV1MetadataFetcherSpy struct { + projectsV1Requested bool +} + +func (mf *projectsV1MetadataFetcherSpy) RepoMetadataFetch(input api.RepoMetadataInput) (*api.RepoMetadataResult, error) { + if input.ProjectsV1 { + mf.projectsV1Requested = true + } + return nil, errors.New("expected test error") +} + func TestTitledEditSurvey_cleanupHint(t *testing.T) { var editorInitialText string editor := &testEditor{ From c08425aef1df7788188c6f92b3c3b31eea53665d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:06:27 -0600 Subject: [PATCH 041/249] fix(projects): use iostreams progress indicator --- pkg/cmd/project/shared/queries/queries.go | 56 ++++++++++------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go index 3e63465dd..70da05ab3 100644 --- a/pkg/cmd/project/shared/queries/queries.go +++ b/pkg/cmd/project/shared/queries/queries.go @@ -7,9 +7,7 @@ import ( "net/url" "regexp" "strings" - "time" - "github.com/briandowns/spinner" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/iostreams" @@ -24,7 +22,7 @@ func NewClient(httpClient *http.Client, hostname string, ios *iostreams.IOStream } return &Client{ apiClient: apiClient, - spinner: ios.IsStdoutTTY() && ios.IsStderrTTY(), + io: ios, prompter: prompter.New("", ios.In, ios.Out, ios.ErrOut), } } @@ -44,9 +42,10 @@ func NewTestClient(opts ...TestClientOpt) *Client { hostname: "github.com", Client: api.NewClientFromHTTP(http.DefaultClient), } + io, _, _, _ := iostreams.Test() c := &Client{ apiClient: apiClient, - spinner: false, + io: io, prompter: nil, } @@ -80,7 +79,7 @@ type graphqlClient interface { type Client struct { apiClient graphqlClient - spinner bool + io *iostreams.IOStreams prompter iprompter } @@ -89,19 +88,12 @@ const ( LimitMax = 100 // https://docs.github.com/en/graphql/overview/resource-limitations#node-limit ) -// doQuery wraps API calls with a visual spinner -func (c *Client) doQuery(name string, query interface{}, variables map[string]interface{}) error { - var sp *spinner.Spinner - if c.spinner { - // https://github.com/briandowns/spinner#available-character-sets - dotStyle := spinner.CharSets[11] - sp = spinner.New(dotStyle, 120*time.Millisecond, spinner.WithColor("fgCyan")) - sp.Start() - } +// doQueryWithProgressIndicator wraps API calls with a progress indicator. +// The query name is used in the progress indicator label. +func (c *Client) doQueryWithProgressIndicator(name string, query interface{}, variables map[string]interface{}) error { + c.io.StartProgressIndicatorWithLabel(fmt.Sprintf("Fetching %s", name)) + defer c.io.StopProgressIndicator() err := c.apiClient.Query(name, query, variables) - if sp != nil { - sp.Stop() - } return handleError(err) } @@ -552,7 +544,7 @@ func (c *Client) ProjectItems(o *Owner, number int32, limit int) (*Project, erro query = &viewerOwnerWithItems{} // must be a pointer to work with graphql queries queryName = "ViewerProjectWithItems" } - err := c.doQuery(queryName, query, variables) + err := c.doQueryWithProgressIndicator(queryName, query, variables) if err != nil { return project, err } @@ -706,7 +698,7 @@ func paginateAttributes[N projectAttribute](c *Client, p pager[N], variables map // set the cursor to the end of the last page variables[afterKey] = (*githubv4.String)(&cursor) - err := c.doQuery(queryName, p, variables) + err := c.doQueryWithProgressIndicator(queryName, p, variables) if err != nil { return nodes, err } @@ -863,7 +855,7 @@ func (c *Client) ProjectFields(o *Owner, number int32, limit int) (*Project, err query = &viewerOwnerWithFields{} // must be a pointer to work with graphql queries queryName = "ViewerProjectWithFields" } - err := c.doQuery(queryName, query, variables) + err := c.doQueryWithProgressIndicator(queryName, query, variables) if err != nil { return project, err } @@ -977,7 +969,7 @@ const ViewerOwner OwnerType = "VIEWER" // ViewerLoginName returns the login name of the viewer. func (c *Client) ViewerLoginName() (string, error) { var query viewerLogin - err := c.doQuery("Viewer", &query, map[string]interface{}{}) + err := c.doQueryWithProgressIndicator("Viewer", &query, map[string]interface{}{}) if err != nil { return "", err } @@ -988,7 +980,7 @@ func (c *Client) ViewerLoginName() (string, error) { func (c *Client) OwnerIDAndType(login string) (string, OwnerType, error) { if login == "@me" || login == "" { var query viewerLogin - err := c.doQuery("ViewerOwner", &query, nil) + err := c.doQueryWithProgressIndicator("ViewerOwner", &query, nil) if err != nil { return "", "", err } @@ -1009,7 +1001,7 @@ func (c *Client) OwnerIDAndType(login string) (string, OwnerType, error) { } `graphql:"organization(login: $login)"` } - err := c.doQuery("UserOrgOwner", &query, variables) + err := c.doQueryWithProgressIndicator("UserOrgOwner", &query, variables) if err != nil { // Due to the way the queries are structured, we don't know if a login belongs to a user // or to an org, even though they are unique. To deal with this, we try both - if neither @@ -1052,7 +1044,7 @@ func (c *Client) IssueOrPullRequestID(rawURL string) (string, error) { "url": githubv4.URI{URL: uri}, } var query issueOrPullRequest - err = c.doQuery("GetIssueOrPullRequest", &query, variables) + err = c.doQueryWithProgressIndicator("GetIssueOrPullRequest", &query, variables) if err != nil { return "", err } @@ -1114,7 +1106,7 @@ func (c *Client) userOrgLogins() ([]loginTypes, error) { "after": (*githubv4.String)(nil), } - err := c.doQuery("ViewerLoginAndOrgs", &v, variables) + err := c.doQueryWithProgressIndicator("ViewerLoginAndOrgs", &v, variables) if err != nil { return l, err } @@ -1152,7 +1144,7 @@ func (c *Client) paginateOrgLogins(l []loginTypes, cursor string) ([]loginTypes, "after": githubv4.String(cursor), } - err := c.doQuery("ViewerLoginAndOrgs", &v, variables) + err := c.doQueryWithProgressIndicator("ViewerLoginAndOrgs", &v, variables) if err != nil { return l, err } @@ -1247,16 +1239,16 @@ func (c *Client) NewProject(canPrompt bool, o *Owner, number int32, fields bool) if o.Type == UserOwner { var query userOwner variables["login"] = githubv4.String(o.Login) - err := c.doQuery("UserProject", &query, variables) + err := c.doQueryWithProgressIndicator("UserProject", &query, variables) return &query.Owner.Project, err } else if o.Type == OrgOwner { variables["login"] = githubv4.String(o.Login) var query orgOwner - err := c.doQuery("OrgProject", &query, variables) + err := c.doQueryWithProgressIndicator("OrgProject", &query, variables) return &query.Owner.Project, err } else if o.Type == ViewerOwner { var query viewerOwner - err := c.doQuery("ViewerProject", &query, variables) + err := c.doQueryWithProgressIndicator("ViewerProject", &query, variables) return &query.Owner.Project, err } return nil, errors.New("unknown owner type") @@ -1331,7 +1323,7 @@ func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) (Pr // the cost. if t == UserOwner { var query userProjects - if err := c.doQuery("UserProjects", &query, variables); err != nil { + if err := c.doQueryWithProgressIndicator("UserProjects", &query, variables); err != nil { return projects, err } projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...) @@ -1340,7 +1332,7 @@ func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) (Pr projects.TotalCount = query.Owner.Projects.TotalCount } else if t == OrgOwner { var query orgProjects - if err := c.doQuery("OrgProjects", &query, variables); err != nil { + if err := c.doQueryWithProgressIndicator("OrgProjects", &query, variables); err != nil { return projects, err } projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...) @@ -1349,7 +1341,7 @@ func (c *Client) Projects(login string, t OwnerType, limit int, fields bool) (Pr projects.TotalCount = query.Owner.Projects.TotalCount } else if t == ViewerOwner { var query viewerProjects - if err := c.doQuery("ViewerProjects", &query, variables); err != nil { + if err := c.doQueryWithProgressIndicator("ViewerProjects", &query, variables); err != nil { return projects, err } projects.Nodes = append(projects.Nodes, query.Owner.Projects.Nodes...) From c208b1f07549914256b10e59c81f4f4f6f003ca4 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 17 Apr 2025 20:49:37 +0200 Subject: [PATCH 042/249] Feature detect v1 projects on web mode issue create --- api/queries_repo.go | 94 +++++++-------- api/queries_repo_test.go | 171 ++++++++++++++++++++-------- pkg/cmd/issue/create/create.go | 22 ++-- pkg/cmd/issue/create/create_test.go | 68 +++++++++++ pkg/cmd/pr/create/create.go | 25 ++-- pkg/cmd/pr/shared/params.go | 10 +- pkg/cmd/pr/shared/params_test.go | 146 +++++++++++++++++++++++- pkg/cmd/pr/shared/state.go | 14 +-- pkg/cmd/pr/shared/survey.go | 4 +- pkg/cmd/pr/shared/survey_test.go | 4 +- 10 files changed, 420 insertions(+), 138 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 7c33825b0..27e21eb32 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghinstance" "golang.org/x/sync/errgroup" @@ -782,35 +783,54 @@ func (m *RepoMetadataResult) projectV2TitleToID(projectTitle string) (string, bo return "", false } -func ProjectsToPaths(projects []RepoProject, projectsV2 []ProjectV2, names []string) ([]string, error) { - var paths []string - for _, projectName := range names { - found := false - for _, p := range projects { - if strings.EqualFold(projectName, p.Name) { - // format of ResourcePath: /OWNER/REPO/projects/PROJECT_NUMBER or /orgs/ORG/projects/PROJECT_NUMBER or /users/USER/projects/PROJECT_NUBER - // required format of path: OWNER/REPO/PROJECT_NUMBER or ORG/PROJECT_NUMBER or USER/PROJECT_NUMBER - var path string - pathParts := strings.Split(p.ResourcePath, "/") - if pathParts[1] == "orgs" || pathParts[1] == "users" { - path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4]) - } else { - path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4]) +func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string, projectsV1Support gh.ProjectsV1Support) ([]string, error) { + paths := make([]string, 0, len(projectNames)) + matchedPaths := map[string]struct{}{} + + // TODO: ProjectsV1Cleanup + // At this point, we only know the names that the user has provided, so we can't push this conditional up the stack. + // First we'll try to match against v1 projects, if supported + if projectsV1Support == gh.ProjectsV1Supported { + v1Projects, err := v1Projects(client, repo) + if err != nil { + return nil, err + } + + for _, projectName := range projectNames { + for _, p := range v1Projects { + if strings.EqualFold(projectName, p.Name) { + pathParts := strings.Split(p.ResourcePath, "/") + var path string + if pathParts[1] == "orgs" || pathParts[1] == "users" { + path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4]) + } else { + path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4]) + } + paths = append(paths, path) + matchedPaths[projectName] = struct{}{} + break } - paths = append(paths, path) - found = true - break } } - if found { + } + + // Then we'll try to match against v2 projects + v2Projects, err := v2Projects(client, repo) + if err != nil { + return nil, err + } + + for _, projectName := range projectNames { + // If we already found a v1 project with this name, skip it + if _, ok := matchedPaths[projectName]; ok { continue } - for _, p := range projectsV2 { + + found := false + for _, p := range v2Projects { if strings.EqualFold(projectName, p.Title) { - // format of ResourcePath: /OWNER/REPO/projects/PROJECT_NUMBER or /orgs/ORG/projects/PROJECT_NUMBER or /users/USER/projects/PROJECT_NUBER - // required format of path: OWNER/REPO/PROJECT_NUMBER or ORG/PROJECT_NUMBER or USER/PROJECT_NUMBER - var path string pathParts := strings.Split(p.ResourcePath, "/") + var path string if pathParts[1] == "orgs" || pathParts[1] == "users" { path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4]) } else { @@ -821,10 +841,12 @@ func ProjectsToPaths(projects []RepoProject, projectsV2 []ProjectV2, names []str break } } + if !found { return nil, fmt.Errorf("'%s' not found", projectName) } } + return paths, nil } @@ -1253,14 +1275,6 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo return milestones, nil } -func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string) ([]string, error) { - projects, projectsV2, err := relevantProjects(client, repo) - if err != nil { - return nil, err - } - return ProjectsToPaths(projects, projectsV2, projectNames) -} - // v1Projects retrieves set of RepoProjects relevant to given repository: // - Projects for repository // - Projects for repository organization, if it belongs to one @@ -1366,26 +1380,6 @@ func v2Projects(client *Client, repo ghrepo.Interface) ([]ProjectV2, error) { return projectsV2, nil } -// relevantProjects retrieves set of Project or ProjectV2 relevant to given repository: -// - Projects for repository -// - Projects for repository organization, if it belongs to one -// - ProjectsV2 owned by current user -// - ProjectsV2 linked to repository -// - ProjectsV2 owned by repository organization, if it belongs to one -func relevantProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, []ProjectV2, error) { - v1Projects, err := v1Projects(client, repo) - if err != nil { - return nil, nil, err - } - - v2Projects, err := v2Projects(client, repo) - if err != nil { - return nil, nil, err - } - - return v1Projects, v2Projects, nil -} - func CreateRepoTransformToV4(apiClient *Client, hostname string, method string, path string, body io.Reader) (*Repository, error) { var responsev3 repositoryV3 err := apiClient.REST(hostname, method, path, body, &responsev3) diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 5f5f55b67..72ed35776 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" @@ -212,37 +213,16 @@ func Test_RepoMetadata(t *testing.T) { } } -func Test_ProjectsToPaths(t *testing.T) { - expectedProjectPaths := []string{"OWNER/REPO/PROJECT_NUMBER", "ORG/PROJECT_NUMBER", "OWNER/REPO/PROJECT_NUMBER_2"} - projects := []RepoProject{ - {ID: "id1", Name: "My Project", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER"}, - {ID: "id2", Name: "Org Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER"}, - {ID: "id3", Name: "Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER_2"}, - } - projectsV2 := []ProjectV2{ - {ID: "id4", Title: "My Project V2", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER_2"}, - {ID: "id5", Title: "Org Project V2", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER_3"}, - } - projectNames := []string{"My Project", "Org Project", "My Project V2"} - - projectPaths, err := ProjectsToPaths(projects, projectsV2, projectNames) - if err != nil { - t.Errorf("error resolving projects: %v", err) - } - if !sliceEqual(projectPaths, expectedProjectPaths) { - t.Errorf("expected projects %v, got %v", expectedProjectPaths, projectPaths) - } -} - func Test_ProjectNamesToPaths(t *testing.T) { - http := &httpmock.Registry{} - client := newTestClient(http) + t.Run("when projectsV1 is supported, requests them", func(t *testing.T) { + http := &httpmock.Registry{} + client := newTestClient(http) - repo, _ := ghrepo.FromFullName("OWNER/REPO") + repo, _ := ghrepo.FromFullName("OWNER/REPO") - http.Register( - httpmock.GraphQL(`query RepositoryProjectList\b`), - httpmock.StringResponse(` + http.Register( + httpmock.GraphQL(`query RepositoryProjectList\b`), + httpmock.StringResponse(` { "data": { "repository": { "projects": { "nodes": [ { "name": "Cleanup", "id": "CLEANUPID", "resourcePath": "/OWNER/REPO/projects/1" }, @@ -251,9 +231,9 @@ func Test_ProjectNamesToPaths(t *testing.T) { "pageInfo": { "hasNextPage": false } } } } } `)) - http.Register( - httpmock.GraphQL(`query OrganizationProjectList\b`), - httpmock.StringResponse(` + http.Register( + httpmock.GraphQL(`query OrganizationProjectList\b`), + httpmock.StringResponse(` { "data": { "organization": { "projects": { "nodes": [ { "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" } @@ -261,9 +241,9 @@ func Test_ProjectNamesToPaths(t *testing.T) { "pageInfo": { "hasNextPage": false } } } } } `)) - http.Register( - httpmock.GraphQL(`query RepositoryProjectV2List\b`), - httpmock.StringResponse(` + http.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` { "data": { "repository": { "projectsV2": { "nodes": [ { "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/3" }, @@ -272,9 +252,9 @@ func Test_ProjectNamesToPaths(t *testing.T) { "pageInfo": { "hasNextPage": false } } } } } `)) - http.Register( - httpmock.GraphQL(`query OrganizationProjectV2List\b`), - httpmock.StringResponse(` + http.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` { "data": { "organization": { "projectsV2": { "nodes": [ { "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2" } @@ -282,9 +262,9 @@ func Test_ProjectNamesToPaths(t *testing.T) { "pageInfo": { "hasNextPage": false } } } } } `)) - http.Register( - httpmock.GraphQL(`query UserProjectV2List\b`), - httpmock.StringResponse(` + http.Register( + httpmock.GraphQL(`query UserProjectV2List\b`), + httpmock.StringResponse(` { "data": { "viewer": { "projectsV2": { "nodes": [ { "title": "MonalisaV2", "id": "MONALISAV2ID", "resourcePath": "/users/MONALISA/projects/5" } @@ -293,15 +273,110 @@ func Test_ProjectNamesToPaths(t *testing.T) { } } } } `)) - projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"}, gh.ProjectsV1Supported) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } - expectedProjectPaths := []string{"ORG/1", "OWNER/REPO/2", "ORG/2", "OWNER/REPO/4", "MONALISA/5"} - if !sliceEqual(projectPaths, expectedProjectPaths) { - t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths) - } + expectedProjectPaths := []string{"ORG/1", "OWNER/REPO/2", "ORG/2", "OWNER/REPO/4", "MONALISA/5"} + if !sliceEqual(projectPaths, expectedProjectPaths) { + t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths) + } + }) + + t.Run("when projectsV1 is not supported, does not request them", func(t *testing.T) { + http := &httpmock.Registry{} + client := newTestClient(http) + + repo, _ := ghrepo.FromFullName("OWNER/REPO") + + http.Exclude( + t, + httpmock.GraphQL(`query RepositoryProjectList\b`), + ) + http.Exclude( + t, + httpmock.GraphQL(`query OrganizationProjectList\b`), + ) + + http.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [ + { "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/3" }, + { "title": "RoadmapV2", "id": "ROADMAPV2ID", "resourcePath": "/OWNER/REPO/projects/4" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [ + { "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query UserProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "viewer": { "projectsV2": { + "nodes": [ + { "title": "MonalisaV2", "id": "MONALISAV2ID", "resourcePath": "/users/MONALISA/projects/5" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + + projectPaths, err := ProjectNamesToPaths(client, repo, []string{"TriageV2", "RoadmapV2", "MonalisaV2"}, gh.ProjectsV1Unsupported) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + expectedProjectPaths := []string{"ORG/2", "OWNER/REPO/4", "MONALISA/5"} + if !sliceEqual(projectPaths, expectedProjectPaths) { + t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths) + } + }) + + t.Run("when a project is not found, returns an error", func(t *testing.T) { + http := &httpmock.Registry{} + client := newTestClient(http) + + repo, _ := ghrepo.FromFullName("OWNER/REPO") + + // No projects found + http.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`query UserProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "viewer": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + + _, err := ProjectNamesToPaths(client, repo, []string{"TriageV2"}, gh.ProjectsV1Unsupported) + require.Equal(t, err, fmt.Errorf("'TriageV2' not found")) + }) } func Test_RepoResolveMetadataIDs(t *testing.T) { diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 0976fd94f..2978a21fc 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -173,13 +173,13 @@ func createRun(opts *CreateOptions) (err error) { } tb := prShared.IssueMetadataState{ - Type: prShared.IssueMetadata, - Assignees: assignees, - Labels: opts.Labels, - Projects: opts.Projects, - Milestones: milestones, - Title: opts.Title, - Body: opts.Body, + Type: prShared.IssueMetadata, + Assignees: assignees, + Labels: opts.Labels, + ProjectTitles: opts.Projects, + Milestones: milestones, + Title: opts.Title, + Body: opts.Body, } if opts.RecoverFile != "" { @@ -195,7 +195,7 @@ func createRun(opts *CreateOptions) (err error) { if opts.WebMode { var openURL string if opts.Title != "" || opts.Body != "" || tb.HasMetadata() { - openURL, err = generatePreviewURL(apiClient, baseRepo, tb) + openURL, err = generatePreviewURL(apiClient, baseRepo, tb, projectsV1Support) if err != nil { return } @@ -273,7 +273,7 @@ func createRun(opts *CreateOptions) (err error) { } } - openURL, err = generatePreviewURL(apiClient, baseRepo, tb) + openURL, err = generatePreviewURL(apiClient, baseRepo, tb, projectsV1Support) if err != nil { return } @@ -367,7 +367,7 @@ func createRun(opts *CreateOptions) (err error) { return } -func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prShared.IssueMetadataState) (string, error) { +func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prShared.IssueMetadataState, projectsV1Support gh.ProjectsV1Support) (string, error) { openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") - return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb) + return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb, projectsV1Support) } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index d8c1c5d92..1211c0c1d 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -474,6 +474,7 @@ func Test_createRun(t *testing.T) { opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } + opts.Detector = &fd.EnabledDetectorMock{} browser := &browser.Stub{} opts.Browser = browser @@ -1103,4 +1104,71 @@ func TestProjectsV1Deprecation(t *testing.T) { reg.Verify(t) }) }) + + t.Run("web mode", func(t *testing.T) { + t.Run("when projects v1 is supported, queries for it", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Register( + // ( is required to avoid matching projectsV2 + httpmock.GraphQL(`projects\(`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = createRun(&CreateOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + + Detector: &fd.EnabledDetectorMock{}, + WebMode: true, + // Required to force a lookup of projects + Projects: []string{"Project"}, + }) + + // Verify that our request contained projects + reg.Verify(t) + }) + + t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + // ( is required to avoid matching projectsV2 + reg.Exclude(t, httpmock.GraphQL(`projects\(`)) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we're not really interested in it. + _ = createRun(&CreateOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + + Detector: &fd.DisabledDetectorMock{}, + WebMode: true, + // Required to force a lookup of projects + Projects: []string{"Project"}, + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) + }) } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 27c929dc4..7f960bce4 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -625,13 +625,13 @@ func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadata } state := &shared.IssueMetadataState{ - Type: shared.PRMetadata, - Reviewers: opts.Reviewers, - Assignees: assignees, - Labels: opts.Labels, - Projects: opts.Projects, - Milestones: milestoneTitles, - Draft: opts.IsDraft, + Type: shared.PRMetadata, + Reviewers: opts.Reviewers, + Assignees: assignees, + Labels: opts.Labels, + ProjectTitles: opts.Projects, + Milestones: milestoneTitles, + Draft: opts.IsDraft, } if opts.FillVerbose || opts.Autofill || opts.FillFirst || !opts.TitleProvided || !opts.BodyProvided { @@ -1032,8 +1032,8 @@ func renderPullRequestPlain(w io.Writer, params map[string]interface{}, state *s if len(state.Milestones) != 0 { fmt.Fprintf(w, "milestones:\t%v\n", strings.Join(state.Milestones, ", ")) } - if len(state.Projects) != 0 { - fmt.Fprintf(w, "projects:\t%v\n", strings.Join(state.Projects, ", ")) + if len(state.ProjectTitles) != 0 { + fmt.Fprintf(w, "projects:\t%v\n", strings.Join(state.ProjectTitles, ", ")) } fmt.Fprintf(w, "maintainerCanModify:\t%t\n", params["maintainerCanModify"]) fmt.Fprint(w, "body:\n") @@ -1064,8 +1064,8 @@ func renderPullRequestTTY(io *iostreams.IOStreams, params map[string]interface{} if len(state.Milestones) != 0 { fmt.Fprintf(out, "%s: %s\n", cs.Bold("Milestones"), strings.Join(state.Milestones, ", ")) } - if len(state.Projects) != 0 { - fmt.Fprintf(out, "%s: %s\n", cs.Bold("Projects"), strings.Join(state.Projects, ", ")) + if len(state.ProjectTitles) != 0 { + fmt.Fprintf(out, "%s: %s\n", cs.Bold("Projects"), strings.Join(state.ProjectTitles, ", ")) } fmt.Fprintf(out, "%s: %t\n", cs.Bold("MaintainerCanModify"), params["maintainerCanModify"]) @@ -1221,7 +1221,8 @@ func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (str ctx.PRRefs.BaseRepo(), "compare/%s...%s?expand=1", url.PathEscape(ctx.PRRefs.BaseRef()), url.PathEscape(ctx.PRRefs.QualifiedHeadRef())) - url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PRRefs.BaseRepo(), u, state) + // TODO wm: revisit project support + url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PRRefs.BaseRepo(), u, state, gh.ProjectsV1Supported) if err != nil { return "", err } diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 9688a2aca..4f36a80aa 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -12,7 +12,7 @@ import ( "github.com/google/shlex" ) -func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, baseURL string, state IssueMetadataState) (string, error) { +func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, baseURL string, state IssueMetadataState, projectsV1Support gh.ProjectsV1Support) (string, error) { u, err := url.Parse(baseURL) if err != nil { return "", err @@ -35,8 +35,8 @@ func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, ba if len(state.Labels) > 0 { q.Set("labels", strings.Join(state.Labels, ",")) } - if len(state.Projects) > 0 { - projectPaths, err := api.ProjectNamesToPaths(client, baseRepo, state.Projects) + if len(state.ProjectTitles) > 0 { + projectPaths, err := api.ProjectNamesToPaths(client, baseRepo, state.ProjectTitles, projectsV1Support) if err != nil { return "", fmt.Errorf("could not add to project: %w", err) } @@ -72,7 +72,7 @@ func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetada resolveInput.Labels = tb.Labels } - if len(tb.Projects) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Projects) == 0) { + if len(tb.ProjectTitles) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Projects) == 0) { if projectV1Support == gh.ProjectsV1Supported { resolveInput.ProjectsV1 = true } @@ -119,7 +119,7 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par } params["labelIds"] = labelIDs - projectIDs, projectV2IDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects) + projectIDs, projectV2IDs, err := tb.MetadataResult.ProjectsToIDs(tb.ProjectTitles) if err != nil { return fmt.Errorf("could not add to project: %w", err) } diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go index 5f5e674cc..15f00ca4f 100644 --- a/pkg/cmd/pr/shared/params_test.go +++ b/pkg/cmd/pr/shared/params_test.go @@ -2,13 +2,16 @@ package shared import ( "net/http" + "net/url" "reflect" "testing" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_listURLWithQuery(t *testing.T) { @@ -265,7 +268,7 @@ func Test_WithPrAndIssueQueryParams(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := WithPrAndIssueQueryParams(nil, nil, tt.args.baseURL, tt.args.state) + got, err := WithPrAndIssueQueryParams(nil, nil, tt.args.baseURL, tt.args.state, gh.ProjectsV1Supported) if (err != nil) != tt.wantErr { t.Errorf("WithPrAndIssueQueryParams() error = %v, wantErr %v", err, tt.wantErr) return @@ -276,3 +279,144 @@ func Test_WithPrAndIssueQueryParams(t *testing.T) { }) } } + +// TODO projectsV1Deprecation +// Remove this test. +func TestWithPrAndIssueQueryParamsProjectsV1Deprecation(t *testing.T) { + t.Run("when projectsV1 is supported, requests them", func(t *testing.T) { + reg := &httpmock.Registry{} + client := api.NewClientFromHTTP(&http.Client{ + Transport: reg, + }) + + repo, _ := ghrepo.FromFullName("OWNER/REPO") + + reg.Register( + httpmock.GraphQL(`query RepositoryProjectList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projects": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectList\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projects": { + "nodes": [ + { "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query UserProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "viewer": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + + u, err := WithPrAndIssueQueryParams( + client, + repo, + "http://example.com/hey", + IssueMetadataState{ + ProjectTitles: []string{"Triage"}, + }, + gh.ProjectsV1Supported, + ) + require.NoError(t, err) + + url, err := url.Parse(u) + require.NoError(t, err) + + require.Equal( + t, + url.Query().Get("projects"), + "ORG/1", + ) + }) + + t.Run("when projectsV1 is not supported, does not request them", func(t *testing.T) { + reg := &httpmock.Registry{} + client := api.NewClientFromHTTP(&http.Client{ + Transport: reg, + }) + + repo, _ := ghrepo.FromFullName("OWNER/REPO") + + reg.Exclude( + t, + httpmock.GraphQL(`query RepositoryProjectList\b`), + ) + reg.Exclude( + t, + httpmock.GraphQL(`query OrganizationProjectList\b`), + ) + + reg.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [ + { "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query UserProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "viewer": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + + u, err := WithPrAndIssueQueryParams( + client, + repo, + "http://example.com/hey", + IssueMetadataState{ + ProjectTitles: []string{"TriageV2"}, + }, + gh.ProjectsV1Unsupported, + ) + require.NoError(t, err) + + url, err := url.Parse(u) + require.NoError(t, err) + + require.Equal( + t, + url.Query().Get("projects"), + "ORG/2", + ) + }) +} diff --git a/pkg/cmd/pr/shared/state.go b/pkg/cmd/pr/shared/state.go index 143021cb6..7e7da436d 100644 --- a/pkg/cmd/pr/shared/state.go +++ b/pkg/cmd/pr/shared/state.go @@ -25,12 +25,12 @@ type IssueMetadataState struct { Template string - Metadata []string - Reviewers []string - Assignees []string - Labels []string - Projects []string - Milestones []string + Metadata []string + Reviewers []string + Assignees []string + Labels []string + ProjectTitles []string + Milestones []string MetadataResult *api.RepoMetadataResult @@ -49,7 +49,7 @@ func (tb *IssueMetadataState) HasMetadata() bool { return len(tb.Reviewers) > 0 || len(tb.Assignees) > 0 || len(tb.Labels) > 0 || - len(tb.Projects) > 0 || + len(tb.ProjectTitles) > 0 || len(tb.Milestones) > 0 } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 23079a391..bf4476ca1 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -268,7 +268,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface } if isChosen("Projects") { if len(projects) > 0 { - selected, err := p.MultiSelect("Projects", state.Projects, projects) + selected, err := p.MultiSelect("Projects", state.ProjectTitles, projects) if err != nil { return err } @@ -317,7 +317,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface state.Labels = values.Labels } if isChosen("Projects") { - state.Projects = values.Projects + state.ProjectTitles = values.Projects } if isChosen("Milestone") { if values.Milestone != "" && values.Milestone != noMilestone { diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index c094b7556..6895b52ac 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -80,7 +80,7 @@ func TestMetadataSurvey_selectAll(t *testing.T) { assert.Equal(t, []string{"hubot"}, state.Assignees) assert.Equal(t, []string{"monalisa"}, state.Reviewers) assert.Equal(t, []string{"good first issue"}, state.Labels) - assert.Equal(t, []string{"The road to 1.0"}, state.Projects) + assert.Equal(t, []string{"The road to 1.0"}, state.ProjectTitles) assert.Equal(t, []string{}, state.Milestones) } @@ -125,7 +125,7 @@ func TestMetadataSurvey_keepExisting(t *testing.T) { assert.Equal(t, []string{"hubot"}, state.Assignees) assert.Equal(t, []string{"good first issue"}, state.Labels) - assert.Equal(t, []string{"The road to 1.0"}, state.Projects) + assert.Equal(t, []string{"The road to 1.0"}, state.ProjectTitles) } // TODO projectsV1Deprecation From 0aa49b774150628a47b54f2b6a5cf02b08ce1c87 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 17 Apr 2025 21:19:41 +0200 Subject: [PATCH 043/249] Feature detect v1 projects on issue edit --- pkg/cmd/issue/edit/edit.go | 17 ++++++- pkg/cmd/issue/edit/edit_test.go | 87 +++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index accea8add..8386cbcfa 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -5,9 +5,12 @@ import ( "net/http" "sort" "sync" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" shared "github.com/cli/cli/v2/pkg/cmd/issue/shared" @@ -22,6 +25,7 @@ type EditOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Prompter prShared.EditPrompter + Detector fd.Detector DetermineEditor func() (string, error) FieldsToEditSurvey func(prShared.EditPrompter, *prShared.Editable) error @@ -201,7 +205,18 @@ func editRun(opts *EditOptions) error { lookupFields = append(lookupFields, "labels") } if editable.Projects.Edited { - lookupFields = append(lookupFields, "projectCards") + // TODO projectsV1Deprecation + // Remove this section as we should no longer add projectCards + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + } + + projectsV1Support := opts.Detector.ProjectsV1() + if projectsV1Support == gh.ProjectsV1Supported { + lookupFields = append(lookupFields, "projectCards") + } + lookupFields = append(lookupFields, "projectItems") } if editable.Milestone.Edited { diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index a9d43c3ec..c9aa4c409 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -10,7 +10,9 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/run" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -788,3 +790,88 @@ func mockProjectV2ItemUpdate(t *testing.T, reg *httpmock.Registry) { func(inputs map[string]interface{}) {}), ) } + +// TODO projectsV1Deprecation +// Remove this test. +func TestProjectsV1Deprecation(t *testing.T) { + t.Run("when projects v1 is supported, is included in query", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`projectCards`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = editRun(&EditOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Detector: &fd.EnabledDetectorMock{}, + + IssueNumbers: []int{123}, + Editable: prShared.Editable{ + Projects: prShared.EditableProjects{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"Test Project"}, + Edited: true, + }, + }, + }, + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) + + t.Run("when projects v1 is not supported, is not included in query", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Exclude(t, httpmock.GraphQL(`projectCards`)) + + reg.Register( + httpmock.GraphQL(`.*`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we're not really interested in it. + _ = editRun(&EditOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Detector: &fd.DisabledDetectorMock{}, + + IssueNumbers: []int{123}, + Editable: prShared.Editable{ + Projects: prShared.EditableProjects{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"Test Project"}, + Edited: true, + }, + }, + }, + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) +} From f61961907e726bb7ac4a7c64a0fff5a173ea8fa5 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Thu, 17 Apr 2025 15:57:49 -0400 Subject: [PATCH 044/249] Update configuration support for accessible colors - added support for `accessible_colors` configuration setting in `gh config` commandset - updated default configuration file to contain `accessible_colors: disabled` - add `GH_ACCESSIBLE_COLORS` env var to `gh environment` - generated mocks via `go generate ./...` including previously missed prompter changes --- internal/config/config.go | 21 ++++ internal/config/stub.go | 3 + internal/gh/gh.go | 2 + internal/gh/mock/config.go | 44 +++++++ internal/prompter/prompter_mock.go | 192 ++++++++++++++--------------- pkg/cmd/config/list/list_test.go | 1 + pkg/cmd/root/help_topic.go | 2 + 7 files changed, 169 insertions(+), 96 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index e7534dfdb..c621e7fdf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,12 @@ import ( ghConfig "github.com/cli/go-gh/v2/pkg/config" ) +// It is important to note that some of these configuration setting keys are used outside of `cli/cli` +// such as `accessible_colors`, `browser`, and `http_unix_socket` used in `cli/go-gh`. +// +// These configuration settings are defined here to avoid `cli/cli` being changed unexpectedly. const ( + accessibleColorsKey = "accessible_colors" aliasesKey = "aliases" browserKey = "browser" colorLabelsKey = "color_labels" @@ -109,6 +114,11 @@ func (c *cfg) Authentication() gh.AuthConfig { return &AuthConfig{cfg: c.cfg} } +func (c *cfg) AccessibleColors(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, accessibleColorsKey).Unwrap() +} + func (c *cfg) Browser(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, browserKey).Unwrap() @@ -540,6 +550,8 @@ http_unix_socket: browser: # Whether to display labels using their RGB hex color codes in terminals that support truecolor. Supported values: enabled, disabled color_labels: disabled +# Whether customizable, 4-bit accessible colors should be used. Supported values: enabled, disabled +accessible_colors: disabled ` type ConfigOption struct { @@ -619,6 +631,15 @@ var Options = []ConfigOption{ return c.ColorLabels(hostname).Value }, }, + { + Key: accessibleColorsKey, + Description: "whether customizable, 4-bit accessible colors should be used", + DefaultValue: "disabled", + AllowedValues: []string{"enabled", "disabled"}, + CurrentValue: func(c gh.Config, hostname string) string { + return c.AccessibleColors(hostname).Value + }, + }, } func HomeDirPath(subdir string) (string, error) { diff --git a/internal/config/stub.go b/internal/config/stub.go index 78073da4a..8b9f14290 100644 --- a/internal/config/stub.go +++ b/internal/config/stub.go @@ -52,6 +52,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock { }, } } + mock.AccessibleColorsFunc = func(hostname string) gh.ConfigEntry { + return cfg.AccessibleColors(hostname) + } mock.BrowserFunc = func(hostname string) gh.ConfigEntry { return cfg.Browser(hostname) } diff --git a/internal/gh/gh.go b/internal/gh/gh.go index b17c6bd67..8f3e3cd5b 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -35,6 +35,8 @@ type Config interface { // Set provides primitive access for setting configuration values, optionally scoped by host. Set(hostname string, key string, value string) + // AccessibleColors returns the configured accessible_colors setting, optionally scoped by host. + AccessibleColors(hostname string) ConfigEntry // Browser returns the configured browser, optionally scoped by host. Browser(hostname string) ConfigEntry // ColorLabels returns the configured color_label setting, optionally scoped by host. diff --git a/internal/gh/mock/config.go b/internal/gh/mock/config.go index b94cb084d..600eea5c1 100644 --- a/internal/gh/mock/config.go +++ b/internal/gh/mock/config.go @@ -19,6 +19,9 @@ var _ gh.Config = &ConfigMock{} // // // make and configure a mocked gh.Config // mockedConfig := &ConfigMock{ +// AccessibleColorsFunc: func(hostname string) gh.ConfigEntry { +// panic("mock out the AccessibleColors method") +// }, // AliasesFunc: func() gh.AliasConfig { // panic("mock out the Aliases method") // }, @@ -74,6 +77,9 @@ var _ gh.Config = &ConfigMock{} // // } type ConfigMock struct { + // AccessibleColorsFunc mocks the AccessibleColors method. + AccessibleColorsFunc func(hostname string) gh.ConfigEntry + // AliasesFunc mocks the Aliases method. AliasesFunc func() gh.AliasConfig @@ -124,6 +130,11 @@ type ConfigMock struct { // calls tracks calls to the methods. calls struct { + // AccessibleColors holds details about calls to the AccessibleColors method. + AccessibleColors []struct { + // Hostname is the hostname argument value. + Hostname string + } // Aliases holds details about calls to the Aliases method. Aliases []struct { } @@ -201,6 +212,7 @@ type ConfigMock struct { Write []struct { } } + lockAccessibleColors sync.RWMutex lockAliases sync.RWMutex lockAuthentication sync.RWMutex lockBrowser sync.RWMutex @@ -219,6 +231,38 @@ type ConfigMock struct { lockWrite sync.RWMutex } +// AccessibleColors calls AccessibleColorsFunc. +func (mock *ConfigMock) AccessibleColors(hostname string) gh.ConfigEntry { + if mock.AccessibleColorsFunc == nil { + panic("ConfigMock.AccessibleColorsFunc: method is nil but Config.AccessibleColors was just called") + } + callInfo := struct { + Hostname string + }{ + Hostname: hostname, + } + mock.lockAccessibleColors.Lock() + mock.calls.AccessibleColors = append(mock.calls.AccessibleColors, callInfo) + mock.lockAccessibleColors.Unlock() + return mock.AccessibleColorsFunc(hostname) +} + +// AccessibleColorsCalls gets all the calls that were made to AccessibleColors. +// Check the length with: +// +// len(mockedConfig.AccessibleColorsCalls()) +func (mock *ConfigMock) AccessibleColorsCalls() []struct { + Hostname string +} { + var calls []struct { + Hostname string + } + mock.lockAccessibleColors.RLock() + calls = mock.calls.AccessibleColors + mock.lockAccessibleColors.RUnlock() + return calls +} + // Aliases calls AliasesFunc. func (mock *ConfigMock) Aliases() gh.AliasConfig { if mock.AliasesFunc == nil { diff --git a/internal/prompter/prompter_mock.go b/internal/prompter/prompter_mock.go index b817a491f..b15f8bf96 100644 --- a/internal/prompter/prompter_mock.go +++ b/internal/prompter/prompter_mock.go @@ -20,28 +20,28 @@ var _ Prompter = &PrompterMock{} // AuthTokenFunc: func() (string, error) { // panic("mock out the AuthToken method") // }, -// ConfirmFunc: func(s string, b bool) (bool, error) { +// ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { // panic("mock out the Confirm method") // }, -// ConfirmDeletionFunc: func(s string) error { +// ConfirmDeletionFunc: func(requiredValue string) error { // panic("mock out the ConfirmDeletion method") // }, -// InputFunc: func(s1 string, s2 string) (string, error) { +// InputFunc: func(prompt string, defaultValue string) (string, error) { // panic("mock out the Input method") // }, // InputHostnameFunc: func() (string, error) { // panic("mock out the InputHostname method") // }, -// MarkdownEditorFunc: func(s1 string, s2 string, b bool) (string, error) { +// MarkdownEditorFunc: func(prompt string, defaultValue string, blankAllowed bool) (string, error) { // panic("mock out the MarkdownEditor method") // }, // MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { // panic("mock out the MultiSelect method") // }, -// PasswordFunc: func(s string) (string, error) { +// PasswordFunc: func(prompt string) (string, error) { // panic("mock out the Password method") // }, -// SelectFunc: func(s1 string, s2 string, strings []string) (int, error) { +// SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { // panic("mock out the Select method") // }, // } @@ -55,28 +55,28 @@ type PrompterMock struct { AuthTokenFunc func() (string, error) // ConfirmFunc mocks the Confirm method. - ConfirmFunc func(s string, b bool) (bool, error) + ConfirmFunc func(prompt string, defaultValue bool) (bool, error) // ConfirmDeletionFunc mocks the ConfirmDeletion method. - ConfirmDeletionFunc func(s string) error + ConfirmDeletionFunc func(requiredValue string) error // InputFunc mocks the Input method. - InputFunc func(s1 string, s2 string) (string, error) + InputFunc func(prompt string, defaultValue string) (string, error) // InputHostnameFunc mocks the InputHostname method. InputHostnameFunc func() (string, error) // MarkdownEditorFunc mocks the MarkdownEditor method. - MarkdownEditorFunc func(s1 string, s2 string, b bool) (string, error) + MarkdownEditorFunc func(prompt string, defaultValue string, blankAllowed bool) (string, error) // MultiSelectFunc mocks the MultiSelect method. MultiSelectFunc func(prompt string, defaults []string, options []string) ([]int, error) // PasswordFunc mocks the Password method. - PasswordFunc func(s string) (string, error) + PasswordFunc func(prompt string) (string, error) // SelectFunc mocks the Select method. - SelectFunc func(s1 string, s2 string, strings []string) (int, error) + SelectFunc func(prompt string, defaultValue string, options []string) (int, error) // calls tracks calls to the methods. calls struct { @@ -85,34 +85,34 @@ type PrompterMock struct { } // Confirm holds details about calls to the Confirm method. Confirm []struct { - // S is the s argument value. - S string - // B is the b argument value. - B bool + // Prompt is the prompt argument value. + Prompt string + // DefaultValue is the defaultValue argument value. + DefaultValue bool } // ConfirmDeletion holds details about calls to the ConfirmDeletion method. ConfirmDeletion []struct { - // S is the s argument value. - S string + // RequiredValue is the requiredValue argument value. + RequiredValue string } // Input holds details about calls to the Input method. Input []struct { - // S1 is the s1 argument value. - S1 string - // S2 is the s2 argument value. - S2 string + // Prompt is the prompt argument value. + Prompt string + // DefaultValue is the defaultValue argument value. + DefaultValue string } // InputHostname holds details about calls to the InputHostname method. InputHostname []struct { } // MarkdownEditor holds details about calls to the MarkdownEditor method. MarkdownEditor []struct { - // S1 is the s1 argument value. - S1 string - // S2 is the s2 argument value. - S2 string - // B is the b argument value. - B bool + // Prompt is the prompt argument value. + Prompt string + // DefaultValue is the defaultValue argument value. + DefaultValue string + // BlankAllowed is the blankAllowed argument value. + BlankAllowed bool } // MultiSelect holds details about calls to the MultiSelect method. MultiSelect []struct { @@ -125,17 +125,17 @@ type PrompterMock struct { } // Password holds details about calls to the Password method. Password []struct { - // S is the s argument value. - S string + // Prompt is the prompt argument value. + Prompt string } // Select holds details about calls to the Select method. Select []struct { - // S1 is the s1 argument value. - S1 string - // S2 is the s2 argument value. - S2 string - // Strings is the strings argument value. - Strings []string + // Prompt is the prompt argument value. + Prompt string + // DefaultValue is the defaultValue argument value. + DefaultValue string + // Options is the options argument value. + Options []string } } lockAuthToken sync.RWMutex @@ -177,21 +177,21 @@ func (mock *PrompterMock) AuthTokenCalls() []struct { } // Confirm calls ConfirmFunc. -func (mock *PrompterMock) Confirm(s string, b bool) (bool, error) { +func (mock *PrompterMock) Confirm(prompt string, defaultValue bool) (bool, error) { if mock.ConfirmFunc == nil { panic("PrompterMock.ConfirmFunc: method is nil but Prompter.Confirm was just called") } callInfo := struct { - S string - B bool + Prompt string + DefaultValue bool }{ - S: s, - B: b, + Prompt: prompt, + DefaultValue: defaultValue, } mock.lockConfirm.Lock() mock.calls.Confirm = append(mock.calls.Confirm, callInfo) mock.lockConfirm.Unlock() - return mock.ConfirmFunc(s, b) + return mock.ConfirmFunc(prompt, defaultValue) } // ConfirmCalls gets all the calls that were made to Confirm. @@ -199,12 +199,12 @@ func (mock *PrompterMock) Confirm(s string, b bool) (bool, error) { // // len(mockedPrompter.ConfirmCalls()) func (mock *PrompterMock) ConfirmCalls() []struct { - S string - B bool + Prompt string + DefaultValue bool } { var calls []struct { - S string - B bool + Prompt string + DefaultValue bool } mock.lockConfirm.RLock() calls = mock.calls.Confirm @@ -213,19 +213,19 @@ func (mock *PrompterMock) ConfirmCalls() []struct { } // ConfirmDeletion calls ConfirmDeletionFunc. -func (mock *PrompterMock) ConfirmDeletion(s string) error { +func (mock *PrompterMock) ConfirmDeletion(requiredValue string) error { if mock.ConfirmDeletionFunc == nil { panic("PrompterMock.ConfirmDeletionFunc: method is nil but Prompter.ConfirmDeletion was just called") } callInfo := struct { - S string + RequiredValue string }{ - S: s, + RequiredValue: requiredValue, } mock.lockConfirmDeletion.Lock() mock.calls.ConfirmDeletion = append(mock.calls.ConfirmDeletion, callInfo) mock.lockConfirmDeletion.Unlock() - return mock.ConfirmDeletionFunc(s) + return mock.ConfirmDeletionFunc(requiredValue) } // ConfirmDeletionCalls gets all the calls that were made to ConfirmDeletion. @@ -233,10 +233,10 @@ func (mock *PrompterMock) ConfirmDeletion(s string) error { // // len(mockedPrompter.ConfirmDeletionCalls()) func (mock *PrompterMock) ConfirmDeletionCalls() []struct { - S string + RequiredValue string } { var calls []struct { - S string + RequiredValue string } mock.lockConfirmDeletion.RLock() calls = mock.calls.ConfirmDeletion @@ -245,21 +245,21 @@ func (mock *PrompterMock) ConfirmDeletionCalls() []struct { } // Input calls InputFunc. -func (mock *PrompterMock) Input(s1 string, s2 string) (string, error) { +func (mock *PrompterMock) Input(prompt string, defaultValue string) (string, error) { if mock.InputFunc == nil { panic("PrompterMock.InputFunc: method is nil but Prompter.Input was just called") } callInfo := struct { - S1 string - S2 string + Prompt string + DefaultValue string }{ - S1: s1, - S2: s2, + Prompt: prompt, + DefaultValue: defaultValue, } mock.lockInput.Lock() mock.calls.Input = append(mock.calls.Input, callInfo) mock.lockInput.Unlock() - return mock.InputFunc(s1, s2) + return mock.InputFunc(prompt, defaultValue) } // InputCalls gets all the calls that were made to Input. @@ -267,12 +267,12 @@ func (mock *PrompterMock) Input(s1 string, s2 string) (string, error) { // // len(mockedPrompter.InputCalls()) func (mock *PrompterMock) InputCalls() []struct { - S1 string - S2 string + Prompt string + DefaultValue string } { var calls []struct { - S1 string - S2 string + Prompt string + DefaultValue string } mock.lockInput.RLock() calls = mock.calls.Input @@ -308,23 +308,23 @@ func (mock *PrompterMock) InputHostnameCalls() []struct { } // MarkdownEditor calls MarkdownEditorFunc. -func (mock *PrompterMock) MarkdownEditor(s1 string, s2 string, b bool) (string, error) { +func (mock *PrompterMock) MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error) { if mock.MarkdownEditorFunc == nil { panic("PrompterMock.MarkdownEditorFunc: method is nil but Prompter.MarkdownEditor was just called") } callInfo := struct { - S1 string - S2 string - B bool + Prompt string + DefaultValue string + BlankAllowed bool }{ - S1: s1, - S2: s2, - B: b, + Prompt: prompt, + DefaultValue: defaultValue, + BlankAllowed: blankAllowed, } mock.lockMarkdownEditor.Lock() mock.calls.MarkdownEditor = append(mock.calls.MarkdownEditor, callInfo) mock.lockMarkdownEditor.Unlock() - return mock.MarkdownEditorFunc(s1, s2, b) + return mock.MarkdownEditorFunc(prompt, defaultValue, blankAllowed) } // MarkdownEditorCalls gets all the calls that were made to MarkdownEditor. @@ -332,14 +332,14 @@ func (mock *PrompterMock) MarkdownEditor(s1 string, s2 string, b bool) (string, // // len(mockedPrompter.MarkdownEditorCalls()) func (mock *PrompterMock) MarkdownEditorCalls() []struct { - S1 string - S2 string - B bool + Prompt string + DefaultValue string + BlankAllowed bool } { var calls []struct { - S1 string - S2 string - B bool + Prompt string + DefaultValue string + BlankAllowed bool } mock.lockMarkdownEditor.RLock() calls = mock.calls.MarkdownEditor @@ -388,19 +388,19 @@ func (mock *PrompterMock) MultiSelectCalls() []struct { } // Password calls PasswordFunc. -func (mock *PrompterMock) Password(s string) (string, error) { +func (mock *PrompterMock) Password(prompt string) (string, error) { if mock.PasswordFunc == nil { panic("PrompterMock.PasswordFunc: method is nil but Prompter.Password was just called") } callInfo := struct { - S string + Prompt string }{ - S: s, + Prompt: prompt, } mock.lockPassword.Lock() mock.calls.Password = append(mock.calls.Password, callInfo) mock.lockPassword.Unlock() - return mock.PasswordFunc(s) + return mock.PasswordFunc(prompt) } // PasswordCalls gets all the calls that were made to Password. @@ -408,10 +408,10 @@ func (mock *PrompterMock) Password(s string) (string, error) { // // len(mockedPrompter.PasswordCalls()) func (mock *PrompterMock) PasswordCalls() []struct { - S string + Prompt string } { var calls []struct { - S string + Prompt string } mock.lockPassword.RLock() calls = mock.calls.Password @@ -420,23 +420,23 @@ func (mock *PrompterMock) PasswordCalls() []struct { } // Select calls SelectFunc. -func (mock *PrompterMock) Select(s1 string, s2 string, strings []string) (int, error) { +func (mock *PrompterMock) Select(prompt string, defaultValue string, options []string) (int, error) { if mock.SelectFunc == nil { panic("PrompterMock.SelectFunc: method is nil but Prompter.Select was just called") } callInfo := struct { - S1 string - S2 string - Strings []string + Prompt string + DefaultValue string + Options []string }{ - S1: s1, - S2: s2, - Strings: strings, + Prompt: prompt, + DefaultValue: defaultValue, + Options: options, } mock.lockSelect.Lock() mock.calls.Select = append(mock.calls.Select, callInfo) mock.lockSelect.Unlock() - return mock.SelectFunc(s1, s2, strings) + return mock.SelectFunc(prompt, defaultValue, options) } // SelectCalls gets all the calls that were made to Select. @@ -444,14 +444,14 @@ func (mock *PrompterMock) Select(s1 string, s2 string, strings []string) (int, e // // len(mockedPrompter.SelectCalls()) func (mock *PrompterMock) SelectCalls() []struct { - S1 string - S2 string - Strings []string + Prompt string + DefaultValue string + Options []string } { var calls []struct { - S1 string - S2 string - Strings []string + Prompt string + DefaultValue string + Options []string } mock.lockSelect.RLock() calls = mock.calls.Select diff --git a/pkg/cmd/config/list/list_test.go b/pkg/cmd/config/list/list_test.go index 2184d0f16..2a1dd72ee 100644 --- a/pkg/cmd/config/list/list_test.go +++ b/pkg/cmd/config/list/list_test.go @@ -101,6 +101,7 @@ func Test_listRun(t *testing.T) { http_unix_socket= browser=brave color_labels=disabled + accessible_colors=disabled `), }, } diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index 4b692777c..0c9534306 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -84,6 +84,8 @@ var HelpTopics = []helpTopic{ %[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_ACCESSIBLE_COLORS%[1]s (preview): set to a truthy value to use customizable, 4-bit accessible colors. + %[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 b63084cab57d551a909a9a74cd103b5e865281f8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Apr 2025 14:47:00 +0000 Subject: [PATCH 045/249] chore(deps): bump github.com/gabriel-vasile/mimetype from 1.4.8 to 1.4.9 Bumps [github.com/gabriel-vasile/mimetype](https://github.com/gabriel-vasile/mimetype) from 1.4.8 to 1.4.9. - [Release notes](https://github.com/gabriel-vasile/mimetype/releases) - [Commits](https://github.com/gabriel-vasile/mimetype/compare/v1.4.8...v1.4.9) --- updated-dependencies: - dependency-name: github.com/gabriel-vasile/mimetype dependency-version: 1.4.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 31b07f2cf..435dd049d 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/creack/pty v1.1.24 github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 github.com/distribution/reference v0.6.0 - github.com/gabriel-vasile/mimetype v1.4.8 + github.com/gabriel-vasile/mimetype v1.4.9 github.com/gdamore/tcell/v2 v2.5.4 github.com/golang/snappy v0.0.4 github.com/google/go-cmp v0.7.0 @@ -182,7 +182,7 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect golang.org/x/mod v0.24.0 // indirect - golang.org/x/net v0.38.0 // indirect + golang.org/x/net v0.39.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-20250303144028-a0af3efb3deb // indirect diff --git a/go.sum b/go.sum index b312bcf6c..d0cdef6a6 100644 --- a/go.sum +++ b/go.sum @@ -183,8 +183,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 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/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/k= @@ -554,8 +554,8 @@ 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.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 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= From 486c58966591e2890d89b9cc1d7f3a659d508496 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Mon, 21 Apr 2025 14:04:00 -0400 Subject: [PATCH 046/249] PR feedback to improve config settings docs --- internal/config/config.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index c621e7fdf..6e780d2bd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,20 +14,18 @@ import ( ghConfig "github.com/cli/go-gh/v2/pkg/config" ) -// It is important to note that some of these configuration setting keys are used outside of `cli/cli` -// such as `accessible_colors`, `browser`, and `http_unix_socket` used in `cli/go-gh`. -// -// These configuration settings are defined here to avoid `cli/cli` being changed unexpectedly. +// Important: some of the following configuration settings are used outside of `cli/cli`, +// they are defined here to avoid `cli/cli` being changed unexpectedly. const ( - accessibleColorsKey = "accessible_colors" + accessibleColorsKey = "accessible_colors" // by cli/go-gh to enable the use of customizable, accessible 4-bit colors. aliasesKey = "aliases" - browserKey = "browser" + browserKey = "browser" // used by cli/go-gh to open URLs in web browsers colorLabelsKey = "color_labels" - editorKey = "editor" + editorKey = "editor" // used by cli/go-gh to open interactive text editor gitProtocolKey = "git_protocol" - hostsKey = "hosts" + hostsKey = "hosts" // used by cli/go-gh to locate authenticated host tokens httpUnixSocketKey = "http_unix_socket" - oauthTokenKey = "oauth_token" + oauthTokenKey = "oauth_token" // used by cli/go-gh to locate authenticated host tokens pagerKey = "pager" promptKey = "prompt" preferEditorPromptKey = "prefer_editor_prompt" From ac9519674816491447a28b7c646db1a4342f4119 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Mon, 21 Apr 2025 14:04:53 -0400 Subject: [PATCH 047/249] Fix comment typo --- internal/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 6e780d2bd..c78dac15c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,7 +17,7 @@ import ( // Important: some of the following configuration settings are used outside of `cli/cli`, // they are defined here to avoid `cli/cli` being changed unexpectedly. const ( - accessibleColorsKey = "accessible_colors" // by cli/go-gh to enable the use of customizable, accessible 4-bit colors. + accessibleColorsKey = "accessible_colors" // used by cli/go-gh to enable the use of customizable, accessible 4-bit colors. aliasesKey = "aliases" browserKey = "browser" // used by cli/go-gh to open URLs in web browsers colorLabelsKey = "color_labels" From 138bccd4377d09fe778f9847a4aac3c31121cdca Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 21 Apr 2025 16:15:41 -0600 Subject: [PATCH 048/249] feat(config): add accessible prompter and spinner --- internal/config/config.go | 34 ++++++ internal/config/stub.go | 6 + internal/gh/gh.go | 4 + internal/gh/mock/config.go | 88 ++++++++++++++ internal/prompter/accessible_prompter_test.go | 34 ++++-- internal/prompter/prompter.go | 24 ++-- pkg/cmd/config/list/list_test.go | 2 + pkg/cmd/factory/default.go | 20 ++- pkg/cmd/factory/default_test.go | 114 ++++++++++++++++++ pkg/cmd/project/shared/queries/queries.go | 2 +- pkg/iostreams/iostreams.go | 13 +- 11 files changed, 314 insertions(+), 27 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index c78dac15c..003a0ca17 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ import ( // they are defined here to avoid `cli/cli` being changed unexpectedly. const ( accessibleColorsKey = "accessible_colors" // used by cli/go-gh to enable the use of customizable, accessible 4-bit colors. + accessiblePrompterKey = "accessible_prompter" aliasesKey = "aliases" browserKey = "browser" // used by cli/go-gh to open URLs in web browsers colorLabelsKey = "color_labels" @@ -29,6 +30,7 @@ const ( pagerKey = "pager" promptKey = "prompt" preferEditorPromptKey = "prefer_editor_prompt" + spinnerKey = "spinner" userKey = "user" usersKey = "users" versionKey = "version" @@ -117,6 +119,11 @@ func (c *cfg) AccessibleColors(hostname string) gh.ConfigEntry { return c.GetOrDefault(hostname, accessibleColorsKey).Unwrap() } +func (c *cfg) AccessiblePrompter(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, accessiblePrompterKey).Unwrap() +} + func (c *cfg) Browser(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, browserKey).Unwrap() @@ -157,6 +164,11 @@ func (c *cfg) PreferEditorPrompt(hostname string) gh.ConfigEntry { return c.GetOrDefault(hostname, preferEditorPromptKey).Unwrap() } +func (c *cfg) Spinner(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, spinnerKey).Unwrap() +} + func (c *cfg) Version() o.Option[string] { return c.get("", versionKey) } @@ -550,6 +562,10 @@ browser: color_labels: disabled # Whether customizable, 4-bit accessible colors should be used. Supported values: enabled, disabled accessible_colors: disabled +# Whether an accessible prompter should be used. Supported values: enabled, disabled +accessible_prompter: disabled +# Whether to use a animated spinner as a progress indicator. If disabled, a textual progress indicator is used instead. Supported values: enabled, disabled +spinner: enabled ` type ConfigOption struct { @@ -638,6 +654,24 @@ var Options = []ConfigOption{ return c.AccessibleColors(hostname).Value }, }, + { + Key: accessiblePrompterKey, + Description: "whether an accessible prompter should be used", + DefaultValue: "disabled", + AllowedValues: []string{"enabled", "disabled"}, + CurrentValue: func(c gh.Config, hostname string) string { + return c.AccessiblePrompter(hostname).Value + }, + }, + { + Key: spinnerKey, + Description: "whether to use a animated spinner as a progress indicator", + DefaultValue: "enabled", + AllowedValues: []string{"enabled", "disabled"}, + CurrentValue: func(c gh.Config, hostname string) string { + return c.Spinner(hostname).Value + }, + }, } func HomeDirPath(subdir string) (string, error) { diff --git a/internal/config/stub.go b/internal/config/stub.go index 8b9f14290..ea60254db 100644 --- a/internal/config/stub.go +++ b/internal/config/stub.go @@ -55,6 +55,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock { mock.AccessibleColorsFunc = func(hostname string) gh.ConfigEntry { return cfg.AccessibleColors(hostname) } + mock.AccessiblePrompterFunc = func(hostname string) gh.ConfigEntry { + return cfg.AccessiblePrompter(hostname) + } mock.BrowserFunc = func(hostname string) gh.ConfigEntry { return cfg.Browser(hostname) } @@ -79,6 +82,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock { mock.PreferEditorPromptFunc = func(hostname string) gh.ConfigEntry { return cfg.PreferEditorPrompt(hostname) } + mock.SpinnerFunc = func(hostname string) gh.ConfigEntry { + return cfg.Spinner(hostname) + } mock.VersionFunc = func() o.Option[string] { return cfg.Version() } diff --git a/internal/gh/gh.go b/internal/gh/gh.go index 8f3e3cd5b..aa90a5268 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -37,6 +37,8 @@ type Config interface { // AccessibleColors returns the configured accessible_colors setting, optionally scoped by host. AccessibleColors(hostname string) ConfigEntry + // AccessiblePrompter returns the configured accessible_prompter setting, optionally scoped by host. + AccessiblePrompter(hostname string) ConfigEntry // Browser returns the configured browser, optionally scoped by host. Browser(hostname string) ConfigEntry // ColorLabels returns the configured color_label setting, optionally scoped by host. @@ -53,6 +55,8 @@ type Config interface { Prompt(hostname string) ConfigEntry // PreferEditorPrompt returns the configured editor-based prompt, optionally scoped by host. PreferEditorPrompt(hostname string) ConfigEntry + // Spinner returns the configured spinner setting, optionally scoped by host. + Spinner(hostname string) ConfigEntry // Aliases provides persistent storage and modification of command aliases. Aliases() AliasConfig diff --git a/internal/gh/mock/config.go b/internal/gh/mock/config.go index 600eea5c1..9f3f80799 100644 --- a/internal/gh/mock/config.go +++ b/internal/gh/mock/config.go @@ -22,6 +22,9 @@ var _ gh.Config = &ConfigMock{} // AccessibleColorsFunc: func(hostname string) gh.ConfigEntry { // panic("mock out the AccessibleColors method") // }, +// AccessiblePrompterFunc: func(hostname string) gh.ConfigEntry { +// panic("mock out the AccessiblePrompter method") +// }, // AliasesFunc: func() gh.AliasConfig { // panic("mock out the Aliases method") // }, @@ -64,6 +67,9 @@ var _ gh.Config = &ConfigMock{} // SetFunc: func(hostname string, key string, value string) { // panic("mock out the Set method") // }, +// SpinnerFunc: func(hostname string) gh.ConfigEntry { +// panic("mock out the Spinner method") +// }, // VersionFunc: func() o.Option[string] { // panic("mock out the Version method") // }, @@ -80,6 +86,9 @@ type ConfigMock struct { // AccessibleColorsFunc mocks the AccessibleColors method. AccessibleColorsFunc func(hostname string) gh.ConfigEntry + // AccessiblePrompterFunc mocks the AccessiblePrompter method. + AccessiblePrompterFunc func(hostname string) gh.ConfigEntry + // AliasesFunc mocks the Aliases method. AliasesFunc func() gh.AliasConfig @@ -122,6 +131,9 @@ type ConfigMock struct { // SetFunc mocks the Set method. SetFunc func(hostname string, key string, value string) + // SpinnerFunc mocks the Spinner method. + SpinnerFunc func(hostname string) gh.ConfigEntry + // VersionFunc mocks the Version method. VersionFunc func() o.Option[string] @@ -135,6 +147,11 @@ type ConfigMock struct { // Hostname is the hostname argument value. Hostname string } + // AccessiblePrompter holds details about calls to the AccessiblePrompter method. + AccessiblePrompter []struct { + // Hostname is the hostname argument value. + Hostname string + } // Aliases holds details about calls to the Aliases method. Aliases []struct { } @@ -205,6 +222,11 @@ type ConfigMock struct { // Value is the value argument value. Value string } + // Spinner holds details about calls to the Spinner method. + Spinner []struct { + // Hostname is the hostname argument value. + Hostname string + } // Version holds details about calls to the Version method. Version []struct { } @@ -213,6 +235,7 @@ type ConfigMock struct { } } lockAccessibleColors sync.RWMutex + lockAccessiblePrompter sync.RWMutex lockAliases sync.RWMutex lockAuthentication sync.RWMutex lockBrowser sync.RWMutex @@ -227,6 +250,7 @@ type ConfigMock struct { lockPreferEditorPrompt sync.RWMutex lockPrompt sync.RWMutex lockSet sync.RWMutex + lockSpinner sync.RWMutex lockVersion sync.RWMutex lockWrite sync.RWMutex } @@ -263,6 +287,38 @@ func (mock *ConfigMock) AccessibleColorsCalls() []struct { return calls } +// AccessiblePrompter calls AccessiblePrompterFunc. +func (mock *ConfigMock) AccessiblePrompter(hostname string) gh.ConfigEntry { + if mock.AccessiblePrompterFunc == nil { + panic("ConfigMock.AccessiblePrompterFunc: method is nil but Config.AccessiblePrompter was just called") + } + callInfo := struct { + Hostname string + }{ + Hostname: hostname, + } + mock.lockAccessiblePrompter.Lock() + mock.calls.AccessiblePrompter = append(mock.calls.AccessiblePrompter, callInfo) + mock.lockAccessiblePrompter.Unlock() + return mock.AccessiblePrompterFunc(hostname) +} + +// AccessiblePrompterCalls gets all the calls that were made to AccessiblePrompter. +// Check the length with: +// +// len(mockedConfig.AccessiblePrompterCalls()) +func (mock *ConfigMock) AccessiblePrompterCalls() []struct { + Hostname string +} { + var calls []struct { + Hostname string + } + mock.lockAccessiblePrompter.RLock() + calls = mock.calls.AccessiblePrompter + mock.lockAccessiblePrompter.RUnlock() + return calls +} + // Aliases calls AliasesFunc. func (mock *ConfigMock) Aliases() gh.AliasConfig { if mock.AliasesFunc == nil { @@ -708,6 +764,38 @@ func (mock *ConfigMock) SetCalls() []struct { return calls } +// Spinner calls SpinnerFunc. +func (mock *ConfigMock) Spinner(hostname string) gh.ConfigEntry { + if mock.SpinnerFunc == nil { + panic("ConfigMock.SpinnerFunc: method is nil but Config.Spinner was just called") + } + callInfo := struct { + Hostname string + }{ + Hostname: hostname, + } + mock.lockSpinner.Lock() + mock.calls.Spinner = append(mock.calls.Spinner, callInfo) + mock.lockSpinner.Unlock() + return mock.SpinnerFunc(hostname) +} + +// SpinnerCalls gets all the calls that were made to Spinner. +// Check the length with: +// +// len(mockedConfig.SpinnerCalls()) +func (mock *ConfigMock) SpinnerCalls() []struct { + Hostname string +} { + var calls []struct { + Hostname string + } + mock.lockSpinner.RLock() + calls = mock.calls.Spinner + mock.lockSpinner.RUnlock() + return calls +} + // Version calls VersionFunc. func (mock *ConfigMock) Version() o.Option[string] { if mock.VersionFunc == nil { diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 56096972d..8ffad10e2 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -11,6 +11,7 @@ import ( "github.com/Netflix/go-expect" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/iostreams" "github.com/creack/pty" "github.com/hinshun/vt10x" "github.com/stretchr/testify/assert" @@ -419,21 +420,40 @@ func newTestVirtualTerminal(t *testing.T) *expect.Console { return console } +func newTestVirtualTerminalIOStreams(t *testing.T, console *expect.Console) *iostreams.IOStreams { + t.Helper() + io := &iostreams.IOStreams{ + In: console.Tty(), + Out: console.Tty(), + ErrOut: console.Tty(), + } + io.SetStdinTTY(false) + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + return io +} + +// `echo` is chosen 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. +var editorCmd = "echo" + func newTestAcessiblePrompter(t *testing.T, console *expect.Console) prompter.Prompter { 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()) + io := newTestVirtualTerminalIOStreams(t, console) + io.SetAccessiblePrompterEnabled(true) + + return prompter.New(editorCmd, io) } func newTestSurveyPrompter(t *testing.T, console *expect.Console) prompter.Prompter { t.Helper() - t.Setenv("GH_ACCESSIBLE_PROMPTER", "false") - return prompter.New("echo", console.Tty(), console.Tty(), console.Tty()) + io := newTestVirtualTerminalIOStreams(t, console) + io.SetAccessiblePrompterEnabled(false) + + return prompter.New(editorCmd, io) } // failOnExpectError adds an observer that will fail the test in a standardised way diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 6ef61cf15..2a4328366 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -2,13 +2,12 @@ package prompter import ( "fmt" - "os" - "slices" "strings" "github.com/AlecAivazis/survey/v2" "github.com/charmbracelet/huh" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/surveyext" ghPrompter "github.com/cli/go-gh/v2/pkg/prompter" ) @@ -43,24 +42,21 @@ type Prompter interface { MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error) } -func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter { - accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER") - falseyValues := []string{"false", "0", "no", ""} - - if accessiblePrompterIsSet && !slices.Contains(falseyValues, accessiblePrompterValue) { +func New(editorCmd string, io *iostreams.IOStreams) Prompter { + if io.AccessiblePrompterEnabled() { return &accessiblePrompter{ - stdin: stdin, - stdout: stdout, - stderr: stderr, + stdin: io.In, + stdout: io.Out, + stderr: io.ErrOut, editorCmd: editorCmd, } } return &surveyPrompter{ - prompter: ghPrompter.New(stdin, stdout, stderr), - stdin: stdin, - stdout: stdout, - stderr: stderr, + prompter: ghPrompter.New(io.In, io.Out, io.ErrOut), + stdin: io.In, + stdout: io.Out, + stderr: io.ErrOut, editorCmd: editorCmd, } } diff --git a/pkg/cmd/config/list/list_test.go b/pkg/cmd/config/list/list_test.go index 2a1dd72ee..27260e857 100644 --- a/pkg/cmd/config/list/list_test.go +++ b/pkg/cmd/config/list/list_test.go @@ -102,6 +102,8 @@ func Test_listRun(t *testing.T) { browser=brave color_labels=disabled accessible_colors=disabled + accessible_prompter=disabled + spinner=enabled `), }, } diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 7a45efeb4..52837b252 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -227,7 +227,7 @@ func newBrowser(f *cmdutil.Factory) browser.Browser { func newPrompter(f *cmdutil.Factory) prompter.Prompter { editor, _ := cmdutil.DetermineEditor(f.Config) io := f.IOStreams - return prompter.New(editor, io.In, io.Out, io.ErrOut) + return prompter.New(editor, io) } func configFunc() func() (gh.Config, error) { @@ -284,9 +284,23 @@ 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) { + + accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER") + if accessiblePrompterIsSet { + if !slices.Contains(falseyValues, accessiblePrompterValue) { + io.SetAccessiblePrompterEnabled(true) + } + } else if prompt := cfg.AccessiblePrompter(""); prompt.Value == "enabled" { + io.SetAccessiblePrompterEnabled(true) + } + + ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED") + if ghSpinnerDisabledIsSet { + if !slices.Contains(falseyValues, ghSpinnerDisabledValue) { + io.SetSpinnerDisabled(true) + } + } else if spinnerDisabled := cfg.Spinner(""); spinnerDisabled.Value == "disabled" { io.SetSpinnerDisabled(true) } diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index 5036a1dc1..d7bfe39fd 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -435,6 +435,7 @@ func Test_ioStreams_prompt(t *testing.T) { func Test_ioStreams_spinnerDisabled(t *testing.T) { tests := []struct { name string + config gh.Config spinnerDisabled bool env map[string]string }{ @@ -442,6 +443,16 @@ func Test_ioStreams_spinnerDisabled(t *testing.T) { name: "default config", spinnerDisabled: false, }, + { + name: "config with spinner disabled", + config: disableSpinnersConfig(), + spinnerDisabled: true, + }, + { + name: "config with spinner enabled", + config: enableSpinnersConfig(), + spinnerDisabled: false, + }, { name: "spinner disabled via GH_SPINNER_DISABLED env var = 0", env: map[string]string{"GH_SPINNER_DISABLED": "0"}, @@ -467,6 +478,18 @@ func Test_ioStreams_spinnerDisabled(t *testing.T) { env: map[string]string{"GH_SPINNER_DISABLED": "true"}, spinnerDisabled: true, }, + { + name: "config enabled but env disabled, respects env", + config: enableSpinnersConfig(), + env: map[string]string{"GH_SPINNER_DISABLED": "true"}, + spinnerDisabled: true, + }, + { + name: "config disabled but env enabled, respects env", + config: disableSpinnersConfig(), + env: map[string]string{"GH_SPINNER_DISABLED": "false"}, + spinnerDisabled: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -474,12 +497,87 @@ func Test_ioStreams_spinnerDisabled(t *testing.T) { t.Setenv(k, v) } f := New("1") + f.Config = func() (gh.Config, error) { + if tt.config == nil { + return config.NewBlankConfig(), nil + } else { + return tt.config, nil + } + } io := ioStreams(f) assert.Equal(t, tt.spinnerDisabled, io.GetSpinnerDisabled()) }) } } +func Test_ioStreams_accessiblePrompterEnabled(t *testing.T) { + tests := []struct { + name string + config gh.Config + accessiblePrompterEnabled bool + env map[string]string + }{ + { + name: "default config", + accessiblePrompterEnabled: false, + }, + { + name: "config with accessible prompter enabled", + config: enableAccessiblePrompterConfig(), + accessiblePrompterEnabled: true, + }, + { + name: "config with accessible prompter disabled", + config: disableAccessiblePrompterConfig(), + accessiblePrompterEnabled: false, + }, + { + name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = 1", + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "1"}, + accessiblePrompterEnabled: true, + }, + { + name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = true", + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, + accessiblePrompterEnabled: true, + }, + { + name: "accessible prompter disabled via GH_ACCESSIBLE_PROMPTER env var = 0", + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "0"}, + accessiblePrompterEnabled: false, + }, + { + name: "config disabled but env enabled, respects env", + config: disableAccessiblePrompterConfig(), + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, + accessiblePrompterEnabled: true, + }, + { + name: "config enabled but env disabled, respects env", + config: enableAccessiblePrompterConfig(), + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "false"}, + accessiblePrompterEnabled: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + f := New("1") + f.Config = func() (gh.Config, error) { + if tt.config == nil { + return config.NewBlankConfig(), nil + } else { + return tt.config, nil + } + } + io := ioStreams(f) + assert.Equal(t, tt.accessiblePrompterEnabled, io.AccessiblePrompterEnabled()) + }) + } +} + func Test_ioStreams_colorLabels(t *testing.T) { tests := []struct { name string @@ -664,6 +762,22 @@ func disablePromptConfig() gh.Config { return config.NewFromString("prompt: disabled") } +func enableAccessiblePrompterConfig() gh.Config { + return config.NewFromString("accessible_prompter: enabled") +} + +func disableAccessiblePrompterConfig() gh.Config { + return config.NewFromString("accessible_prompter: disabled") +} + +func disableSpinnersConfig() gh.Config { + return config.NewFromString("spinner: disabled") +} + +func enableSpinnersConfig() gh.Config { + return config.NewFromString("spinner: enabled") +} + func disableColorLabelsConfig() gh.Config { return config.NewFromString("color_labels: disabled") } diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go index 3e63465dd..87644a4ce 100644 --- a/pkg/cmd/project/shared/queries/queries.go +++ b/pkg/cmd/project/shared/queries/queries.go @@ -25,7 +25,7 @@ func NewClient(httpClient *http.Client, hostname string, ios *iostreams.IOStream return &Client{ apiClient: apiClient, spinner: ios.IsStdoutTTY() && ios.IsStderrTTY(), - prompter: prompter.New("", ios.In, ios.Out, ios.ErrOut), + prompter: prompter.New("", ios), } } diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index ba2cc6b50..22f966ac8 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -58,6 +58,7 @@ type IOStreams struct { progressIndicatorEnabled bool progressIndicator *spinner.Spinner progressIndicatorMu sync.Mutex + spinnerDisabled bool alternateScreenBufferEnabled bool alternateScreenBufferActive bool @@ -78,8 +79,8 @@ type IOStreams struct { pagerCommand string pagerProcess *os.Process - neverPrompt bool - spinnerDisabled bool + neverPrompt bool + accessiblePrompterEnabled bool TempFileOverride *os.File } @@ -457,6 +458,14 @@ func (s *IOStreams) AccessibleColorsEnabled() bool { return s.accessibleColorsEnabled } +func (s *IOStreams) SetAccessiblePrompterEnabled(enabled bool) { + s.accessiblePrompterEnabled = enabled +} + +func (s *IOStreams) AccessiblePrompterEnabled() bool { + return s.accessiblePrompterEnabled +} + func System() *IOStreams { terminal := ghTerm.FromEnv() From 9463b0ee617ca9abe1f0fd76e7d4bd3a08454690 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 22 Apr 2025 17:12:07 -0600 Subject: [PATCH 049/249] test(prompter): correct typo in accessible prompter name --- internal/prompter/accessible_prompter_test.go | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 8ffad10e2..619eb14f1 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -34,7 +34,7 @@ import ( func TestAccessiblePrompter(t *testing.T) { t.Run("Select", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) go func() { // Wait for prompt to appear @@ -53,7 +53,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("MultiSelect", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) go func() { // Wait for prompt to appear @@ -78,7 +78,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("Input", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) dummyText := "12345abcdefg" go func() { @@ -98,7 +98,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("Input - blank input returns default value", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) dummyDefaultValue := "12345abcdefg" go func() { @@ -118,7 +118,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("Password", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) dummyPassword := "12345abcdefg" go func() { @@ -138,7 +138,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("Confirm", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) go func() { // Wait for prompt to appear @@ -157,7 +157,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("Confirm - blank input returns default", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) go func() { // Wait for prompt to appear @@ -176,7 +176,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("AuthToken", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) dummyAuthToken := "12345abcdefg" go func() { @@ -196,7 +196,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("AuthToken - blank input returns error", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) dummyAuthTokenForAfterFailure := "12345abcdefg" go func() { @@ -224,7 +224,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("ConfirmDeletion", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) requiredValue := "test" go func() { @@ -244,7 +244,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("ConfirmDeletion - bad input", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) requiredValue := "test" badInputValue := "garbage" @@ -273,7 +273,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("InputHostname", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) hostname := "example.com" go func() { @@ -293,7 +293,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("MarkdownEditor - blank allowed with blank input returns blank", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) go func() { // Wait for prompt to appear @@ -312,7 +312,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("MarkdownEditor - blank disallowed with default value returns default value", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) defaultValue := "12345abcdefg" go func() { @@ -340,7 +340,7 @@ func TestAccessiblePrompter(t *testing.T) { t.Run("MarkdownEditor - blank disallowed no default value returns error", func(t *testing.T) { console := newTestVirtualTerminal(t) - p := newTestAcessiblePrompter(t, console) + p := newTestAccessiblePrompter(t, console) go func() { // Wait for prompt to appear @@ -438,7 +438,7 @@ func newTestVirtualTerminalIOStreams(t *testing.T, console *expect.Console) *ios // and since this file is only built on Linux, it is near guaranteed to be available. var editorCmd = "echo" -func newTestAcessiblePrompter(t *testing.T, console *expect.Console) prompter.Prompter { +func newTestAccessiblePrompter(t *testing.T, console *expect.Console) prompter.Prompter { t.Helper() io := newTestVirtualTerminalIOStreams(t, console) From 274a09bbc97657163b2eabf64a5c979987fd64c3 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Wed, 23 Apr 2025 10:11:51 -0400 Subject: [PATCH 050/249] Initial `gh accessibility` command draft This commit captures the initial command along with functionality and description. There is an internal discussion about the appropriate place for some of this content. --- pkg/cmd/accessibility/accessibility.go | 150 +++++++++++++++++++++++++ pkg/cmd/root/help.go | 11 +- pkg/cmd/root/root.go | 2 + 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/accessibility/accessibility.go diff --git a/pkg/cmd/accessibility/accessibility.go b/pkg/cmd/accessibility/accessibility.go new file mode 100644 index 000000000..4992d488b --- /dev/null +++ b/pkg/cmd/accessibility/accessibility.go @@ -0,0 +1,150 @@ +package accessibility + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +const ( + communityURL = "https://github.com/orgs/community/discussions/categories/accessibility" +) + +type AccessibilityOptions struct { + IO *iostreams.IOStreams + Browser browser.Browser + Web bool +} + +func NewCmdAccessibility(f *cmdutil.Factory) *cobra.Command { + opts := AccessibilityOptions{ + IO: f.IOStreams, + Browser: f.Browser, + } + + cmd := &cobra.Command{ + Use: "accessibility", + Aliases: []string{"a11y"}, + Short: "Learn about GitHub CLI accessibility experience", + Long: longDescription(opts.IO), + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.Web { + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(communityURL)) + } + return opts.Browser.Browse(communityURL) + } + + return cmd.Help() + }, + Example: heredoc.Doc(` + # Open the GitHub Community Accessibility discussions in your browser + $ gh accessibility --web + + # Display color using customizable, 4-bit accessible colors + $ gh config set accessible_colors enabled + + # Display issue and pull request labels using RGB hex color codes in terminals that support 24-bit truecolor + $ gh config set color_labels enabled + + # Use input prompts without redrawing the screen + $ gh config set accessible_prompter enabled + + # Disable motion-based spinners for progress indicators in favor of text + $ gh config set spinner disabled + `), + } + + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open the GitHub Community Accessibility discussions in the browser") + cmdutil.DisableAuthCheck(cmd) + + return cmd +} + +func longDescription(io *iostreams.IOStreams) string { + cs := io.ColorScheme() + title := cs.Bold("LEARN ABOUT GITHUB CLI ACCESSIBILITY EFFORTS") + color := cs.Bold("CUSTOMIZABLE AND CONTRASTING COLORS") + prompter := cs.Bold("NON-INTERACTIVE USER INPUT PROMPTING") + spinner := cs.Bold("TEXT-BASED SPINNERS") + + return heredoc.Docf(` + %[2]s + + As the home for all developers, we want every developer to feel welcome in our + community and be empowered to contribute to the future of global software + development with everything GitHub has to offer including the GitHub CLI. + + We invite you to join us in improving GitHub CLI accessibility by sharing your + feedback and ideas in the GitHub Community Accessibility discussions: + %[3]s + + + %[4]s + + Color is a common approach to enhance user experiences, however users can find + themselves with a worse experience due to insufficient contrast or + customizability. + + To create an accessible experience, CLIs should use color palettes based on + terminal background appearance and limit colors to 4-bit ANSI color palettes, + which users can customize within terminal preferences. + + With this new experience, the GitHub CLI provides multiple options to address + color usage: + + 1. The GitHub CLI will use 4-bit color palette for increased color contrast based on + dark and light backgrounds including rendering markdown based on GitHub Primer. + + To enable this experience, use one of the following methods: + - Run %[1]sgh config set accessible_colors enabled%[1]s + - Set %[1]sGH_ACCESSIBLE_COLORS=enabled%[1]s environment variable + + 2. The GitHub CLI will display issue and pull request labels' custom RGB colors + in terminals with truecolor support. + + To enable this experience, use one of the following methods: + - Run %[1]sgh config set color_labels enabled%[1]s + - Set %[1]sGH_COLOR_LABELS=enabled%[1]s environment variable + + + %[5]s + + Interactive text user interfaces are an advanced approach to enhance user + experiences, which manipulate the terminal cursor to redraw parts of the screen. + However, this can be difficult for speech synthesizers or braille displays to + accurately detect and read. + + To create an accessible experience, CLIs should give users the ability to disable + this interactivity while providing a similar experience. + + With this new experience, the GitHub CLI will use non-interactive prompts for + user input. + + To enable this experience, use one of the following methods: + - Run %[1]sgh config set accessible_prompter enabled%[1]s + - Set %[1]sGH_ACCESSIBLE_PROMPTER=enabled%[1]s environment variable + + + %[6]s + + Motion-based spinners are a common approach to communicate activity, which + manipulate the terminal cursor to create a spinning effect. However, this can be + difficult for users with motion sensitivity as well as speech synthesizers. + + To create an accessible experience, CLIs should give users the ability to disable + this interactivity while providing a similar experience. + + With this new experience, the GitHub CLI will use text-based progress indicators. + + To enable this experience, use one of the following methods: + - Run %[1]sgh config set spinner disabled%[1]s + - Set %[1]sGH_SPINNER_DISABLED=yes%[1]s environment variable + `, "`", title, communityURL, color, prompter, spinner) +} diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go index 7f8fb1c2e..ec6499f21 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -109,8 +109,6 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) { return } - namePadding := 12 - type helpEntry struct { Title string Body string @@ -135,6 +133,12 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) { helpEntries = append(helpEntries, helpEntry{"ALIASES", strings.Join(BuildAliasList(command, command.Aliases), ", ") + "\n"}) } + // Statically calculated padding for non-extension commands, + // longest is `gh accessibility` with 13 characters + 1 space. + // + // Should consider novel way to calculate this in the future [AF] + namePadding := 14 + for _, g := range GroupedCommands(command) { var names []string for _, c := range g.Commands { @@ -148,6 +152,9 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) { if isRootCmd(command) { var helpTopics []string + if c := findCommand(command, "accessibility"); c != nil { + helpTopics = append(helpTopics, rpad(c.Name()+":", namePadding)+c.Short) + } if c := findCommand(command, "actions"); c != nil { helpTopics = append(helpTopics, rpad(c.Name()+":", namePadding)+c.Short) } diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index c0dad93ec..8cf30db1b 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + accessibilityCmd "github.com/cli/cli/v2/pkg/cmd/accessibility" actionsCmd "github.com/cli/cli/v2/pkg/cmd/actions" aliasCmd "github.com/cli/cli/v2/pkg/cmd/alias" "github.com/cli/cli/v2/pkg/cmd/alias/shared" @@ -122,6 +123,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, // Child commands cmd.AddCommand(versionCmd.NewCmdVersion(f, version, buildDate)) + cmd.AddCommand(accessibilityCmd.NewCmdAccessibility(f)) cmd.AddCommand(actionsCmd.NewCmdActions(f)) cmd.AddCommand(aliasCmd.NewCmdAlias(f)) cmd.AddCommand(authCmd.NewCmdAuth(f)) From 4e68a61a5898c11bcd73c749a3aa570a40a412fc Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 24 Apr 2025 12:25:00 +0200 Subject: [PATCH 051/249] Fix pr create when branch name contains slashes Intentionally have not fixed remote names containing slashes because we want to get a fix out for the vast majority failure case. --- ...mote-from-sha-with-branch-name-slash.txtar | 50 +++++++++++++++++++ .../pr-create-guesses-remote-from-sha.txtar | 2 + ...te-remote-ref-with-branch-name-slash.txtar | 46 +++++++++++++++++ git/client.go | 49 ++++++++++++++++-- git/client_test.go | 27 +++++++++- 5 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar create mode 100644 acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar diff --git a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar new file mode 100644 index 000000000..542579b0a --- /dev/null +++ b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha-with-branch-name-slash.txtar @@ -0,0 +1,50 @@ +skip 'it creates a fork owned by the user running the test' + +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' +# Push without setting an upstream (-u or config) +exec git push upstream feature/branch + +# Prepare an additional commit +exec git commit --allow-empty -m 'Fork Commit' +# Push without setting an upstream (-u or config) +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-guesses-remote-from-sha.txtar b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar index 52579b501..e263b0351 100644 --- a/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar +++ b/acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar @@ -1,3 +1,5 @@ +skip 'it creates a fork owned by the user running the test' + env REPO=${SCRIPT_NAME}-${RANDOM_STRING} env FORK=${REPO}-fork diff --git a/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar b/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.txtar new file mode 100644 index 000000000..395fce86a --- /dev/null +++ b/acceptance/testdata/pr/pr-create-remote-ref-with-branch-name-slash.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 view --json headRefName,headRepository,baseRefName,isCrossRepository +stdout {"baseRefName":"main","headRefName":"feature/branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} diff --git a/git/client.go b/git/client.go index fe2819cf0..5f547c99c 100644 --- a/git/client.go +++ b/git/client.go @@ -518,15 +518,56 @@ func (r RemoteTrackingRef) String() string { // 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. +// +// For now, we assume that refnames are of the format "/", where +// the remote is a single path component, and branch may have many path components e.g. +// "origin/my/branch" is valid as: {Remote: "origin", Branch: "my/branch"} +// but "my/origin/branch" would parse incorrectly as: {Remote: "my", Branch: "origin/branch"} +// I don't believe there is a way to fix this without providing the list of remotes to this function. +// +// It becomes particularly confusing if you have something like: +// +// ``` +// [remote "foo"] +// url = https://github.com/williammartin/test-repo.git +// fetch = +refs/heads/*:refs/remotes/foo/* +// [remote "foo/bar"] +// url = https://github.com/williammartin/test-repo.git +// fetch = +refs/heads/*:refs/remotes/foo/bar/* +// [branch "bar/baz"] +// remote = foo +// merge = refs/heads/bar/baz +// [branch "baz"] +// remote = foo/bar +// merge = refs/heads/baz +// ``` +// +// These @{push} refs would resolve identically: +// +// ``` +// ➜ git rev-parse --symbolic-full-name baz@{push} +// refs/remotes/foo/bar/baz + +// ➜ git rev-parse --symbolic-full-name bar/baz@{push} +// refs/remotes/foo/bar/baz +// ``` +// +// When using this ref, git assumes it means `remote: foo` `branch: bar/baz`. func ParseRemoteTrackingRef(s string) (RemoteTrackingRef, error) { - parts := strings.Split(s, "/") - if len(parts) != 4 || parts[0] != "refs" || parts[1] != "remotes" { + prefix := "refs/remotes/" + if !strings.HasPrefix(s, prefix) { + return RemoteTrackingRef{}, fmt.Errorf("remote tracking branch must have format refs/remotes// but was: %s", s) + } + + refName := strings.TrimPrefix(s, prefix) + refNameParts := strings.SplitN(refName, "/", 2) + if len(refNameParts) != 2 { return RemoteTrackingRef{}, fmt.Errorf("remote tracking branch must have format refs/remotes// but was: %s", s) } return RemoteTrackingRef{ - Remote: parts[2], - Branch: parts[3], + Remote: refNameParts[0], + Branch: refNameParts[1], }, nil } diff --git a/git/client_test.go b/git/client_test.go index 3d7560228..f59b26077 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -1151,13 +1151,38 @@ func TestRemoteTrackingRef(t *testing.T) { wantError error }{ { - name: "valid remote tracking ref", + name: "valid remote tracking ref without slash in branch name", remoteTrackingRef: "refs/remotes/origin/branchName", wantRemoteTrackingRef: RemoteTrackingRef{ Remote: "origin", Branch: "branchName", }, }, + { + name: "valid remote tracking ref with slash in branch name", + remoteTrackingRef: "refs/remotes/origin/branch/name", + wantRemoteTrackingRef: RemoteTrackingRef{ + Remote: "origin", + Branch: "branch/name", + }, + }, + // TODO: Uncomment when we support slashes in remote names + // { + // name: "valid remote tracking ref with slash in remote name", + // remoteTrackingRef: "refs/remotes/my/origin/branchName", + // wantRemoteTrackingRef: RemoteTrackingRef{ + // Remote: "my/origin", + // Branch: "branchName", + // }, + // }, + // { + // name: "valid remote tracking ref with slash in remote name and branch name", + // remoteTrackingRef: "refs/remotes/my/origin/branch/name", + // wantRemoteTrackingRef: RemoteTrackingRef{ + // Remote: "my/origin", + // Branch: "branch/name", + // }, + // }, { name: "incorrect parts", remoteTrackingRef: "refs/remotes/origin", From fcd23dc657892a4cea35ad42c3100339300b28e1 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Thu, 24 Apr 2025 08:55:57 -0600 Subject: [PATCH 052/249] create fetcher with custom http client and retry options Signed-off-by: Meredith Lancaster --- go.mod | 4 +++- pkg/cmd/attestation/verification/sigstore.go | 8 +++++--- pkg/cmd/attestation/verification/tuf.go | 11 +++++++++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 31b07f2cf..cd3aa90cf 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 + github.com/theupdateframework/go-tuf/v2 v2.0.2 github.com/zalando/go-keyring v0.2.5 golang.org/x/crypto v0.37.0 golang.org/x/sync v0.13.0 @@ -59,6 +60,8 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +replace github.com/theupdateframework/go-tuf/v2 => github.com/theupdateframework/go-tuf/v2 e9e0d485966d571ea6870670d1e42553f1b3b2db + require ( dario.cat/mergo v1.0.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect @@ -165,7 +168,6 @@ require ( 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 - github.com/theupdateframework/go-tuf/v2 v2.0.2 // indirect github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 190ea5c0f..34558a4cc 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -6,6 +6,7 @@ import ( "crypto/x509" "errors" "fmt" + "net/http" "os" "github.com/cli/cli/v2/pkg/cmd/attestation/api" @@ -33,6 +34,7 @@ type SigstoreConfig struct { TrustedRoot string Logger *io.Handler NoPublicGood bool + HttpClient *http.Client // If tenancy mode is not used, trust domain is empty TrustDomain string // TUFMetadataDir @@ -77,7 +79,7 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro } liveVerifier.PublicGood = publicGoodVerifier } - github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir) + github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir, config.HttpClient) if err != nil { return nil, err } @@ -314,10 +316,10 @@ func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerif return gv, nil } -func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string]) (*verify.SignedEntityVerifier, error) { +func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string], hc *http.Client) (*verify.SignedEntityVerifier, error) { var tr string - opts := GitHubTUFOptions(tufMetadataDir) + opts := GitHubTUFOptions(tufMetadataDir, hc) client, err := tuf.New(opts) if err != nil { return nil, fmt.Errorf("failed to create TUF client: %v", err) diff --git a/pkg/cmd/attestation/verification/tuf.go b/pkg/cmd/attestation/verification/tuf.go index dcfdd0b32..94455d343 100644 --- a/pkg/cmd/attestation/verification/tuf.go +++ b/pkg/cmd/attestation/verification/tuf.go @@ -8,6 +8,7 @@ import ( o "github.com/cli/cli/v2/pkg/option" "github.com/cli/go-gh/v2/pkg/config" "github.com/sigstore/sigstore-go/pkg/tuf" + "github.com/theupdateframework/go-tuf/v2/metadata/fetcher" ) //go:embed embed/tuf-repo.github.com/root.json @@ -15,7 +16,7 @@ var githubRoot []byte const GitHubTUFMirror = "https://tuf-repo.github.com" -func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string]) *tuf.Options { +func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string], hc *http.Client) *tuf.Options { opts := tuf.DefaultOptions() // The CODESPACES environment variable will be set to true in a Codespaces workspace @@ -32,10 +33,16 @@ func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string]) *tuf.Option // Allow TUF cache for 1 day opts.CacheValidity = 1 + // configure fetcher timeout and retry + f := fetcher.DefaultFetcher{} + f.SetHTTPClient(hc) + retryOptions := []backoff.RetryOption{backoff.WithMaxTries(3)} + f.SetRetryOptions(retryOptions...) + return opts } -func GitHubTUFOptions(tufMetadataDir o.Option[string]) *tuf.Options { +func GitHubTUFOptions(tufMetadataDir o.Option[string], hc *http.Client) *tuf.Options { opts := DefaultOptionsWithCacheSetting(tufMetadataDir) opts.Root = githubRoot From fb97b3efaabaf3a727beb6ed5f4adbf9e780f9ff Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 24 Apr 2025 18:41:14 +0200 Subject: [PATCH 053/249] Fix pr create when push.default tracking and no merge ref (#10863) * Fix pr create when push.default tracking and no merge ref * Update pkg/cmd/pr/shared/find_refs_resolution.go --------- Co-authored-by: Tyler McGoffin --- ...h-default-upstream-no-merge-ref-fork.txtar | 50 +++++++++++++++++++ ...e-push-default-upstream-no-merge-ref.txtar | 33 ++++++++++++ pkg/cmd/pr/shared/find_refs_resolution.go | 8 +-- .../pr/shared/find_refs_resolution_test.go | 8 +-- 4 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar create mode 100644 acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref.txtar diff --git a/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar new file mode 100644 index 000000000..0974f9225 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref-fork.txtar @@ -0,0 +1,50 @@ +skip 'it creates a fork owned by the user running the test' +skip 'this never worked, but could be fixed if we fixed show-refs' + +# 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} + +# Configure push.default so that it should use the merge ref +exec git config push.default upstream + +# But prepare a branch that doesn't have a tracking merge ref +exec git checkout -b feature-branch +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":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true} diff --git a/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref.txtar b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref.txtar new file mode 100644 index 000000000..90c5cde50 --- /dev/null +++ b/acceptance/testdata/pr/pr-create-push-default-upstream-no-merge-ref.txtar @@ -0,0 +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} + +# 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} + +# Clone the repo +exec gh repo clone ${ORG}/${REPO} +cd ${REPO} + +# Configure push.default so that it should use the merge ref +exec git config push.default upstream + +# But prepare a branch that doesn't have a tracking merge ref +exec git checkout -b feature-branch +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 diff --git a/pkg/cmd/pr/shared/find_refs_resolution.go b/pkg/cmd/pr/shared/find_refs_resolution.go index 833075af8..e4e51bab8 100644 --- a/pkg/cmd/pr/shared/find_refs_resolution.go +++ b/pkg/cmd/pr/shared/find_refs_resolution.go @@ -333,12 +333,12 @@ func tryDetermineDefaultPushTarget(gitClient GitConfigClient, localBranchName st } // 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. + // push.default = upstream or tracking, then we use the branch name from the merge ref if it exists. Otherwise, we fall back to the local branch name 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") + mergeRef := strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/") + if mergeRef != "" { + remoteBranch = mergeRef } } diff --git a/pkg/cmd/pr/shared/find_refs_resolution_test.go b/pkg/cmd/pr/shared/find_refs_resolution_test.go index 8cbb62146..d2393bf10 100644 --- a/pkg/cmd/pr/shared/find_refs_resolution_test.go +++ b/pkg/cmd/pr/shared/find_refs_resolution_test.go @@ -462,7 +462,7 @@ func TestTryDetermineDefaultPRHead(t *testing.T) { }) } - t.Run("but if the merge ref is empty, error", func(t *testing.T) { + t.Run("but if the merge ref is empty, use the provided branch name", func(t *testing.T) { t.Parallel() repoResolvedFromPushRemoteClient := stubGitConfigClient{ @@ -474,12 +474,14 @@ func TestTryDetermineDefaultPRHead(t *testing.T) { pushDefaultFn: stubPushDefault(git.PushDefaultUpstream, nil), } - _, err := TryDetermineDefaultPRHead( + defaultPRHead, err := TryDetermineDefaultPRHead( repoResolvedFromPushRemoteClient, stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil), "feature-branch", ) - require.Error(t, err) + require.NoError(t, err) + + require.Equal(t, "feature-branch", defaultPRHead.BranchName) }) }) From abd98bd727521286e0d0179a1c4818d38eb74ab7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 25 Apr 2025 15:00:36 +0000 Subject: [PATCH 054/249] chore(deps): bump github.com/cpuguy83/go-md2man/v2 from 2.0.6 to 2.0.7 Bumps [github.com/cpuguy83/go-md2man/v2](https://github.com/cpuguy83/go-md2man) from 2.0.6 to 2.0.7. - [Release notes](https://github.com/cpuguy83/go-md2man/releases) - [Commits](https://github.com/cpuguy83/go-md2man/compare/v2.0.6...v2.0.7) --- updated-dependencies: - dependency-name: github.com/cpuguy83/go-md2man/v2 dependency-version: 2.0.7 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 31b07f2cf..1ea50709d 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 github.com/cli/oauth v1.1.1 github.com/cli/safeexec v1.0.1 - github.com/cpuguy83/go-md2man/v2 v2.0.6 + github.com/cpuguy83/go-md2man/v2 v2.0.7 github.com/creack/pty v1.1.24 github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 github.com/distribution/reference v0.6.0 diff --git a/go.sum b/go.sum index b312bcf6c..0203b8bfc 100644 --- a/go.sum +++ b/go.sum @@ -142,8 +142,9 @@ 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.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= From 97a3b70599dcba4aae1c24fc36796362505d39ed Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Sat, 26 Apr 2025 12:57:10 -0400 Subject: [PATCH 055/249] Update to huh@0.7.0, echo mode changes This commit is the initial change around updating to huh@0.7.0; pre-testing changes. --- go.mod | 2 +- go.sum | 12 ++++++++++-- internal/prompter/prompter.go | 9 ++++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 31b07f2cf..3562f24a6 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.1-0.20250409210615-c5906631cbb5 + github.com/charmbracelet/huh v0.7.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 diff --git a/go.sum b/go.sum index b312bcf6c..2ac25c2f8 100644 --- a/go.sum +++ b/go.sum @@ -110,20 +110,28 @@ 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.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/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= +github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= 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/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= 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= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 2a4328366..d56374665 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -137,10 +137,12 @@ func (p *accessiblePrompter) Input(prompt, defaultValue 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. + // EchoModeNone and EchoModePassword both result in disabling echo mode + // as password masking is outside of VT100 spec. form := p.newForm( huh.NewGroup( huh.NewInput(). + EchoMode(huh.EchoModeNone). Title(prompt). Value(&result), ), @@ -171,9 +173,12 @@ func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, er func (p *accessiblePrompter) AuthToken() (string, error) { var result string + // EchoModeNone and EchoModePassword both result in disabling echo mode + // as password masking is outside of VT100 spec. form := p.newForm( huh.NewGroup( huh.NewInput(). + EchoMode(huh.EchoModeNone). Title("Paste your authentication token:"). // Note: if this validation fails, the prompt loops. Validate(func(input string) error { @@ -183,8 +188,6 @@ func (p *accessiblePrompter) AuthToken() (string, error) { return nil }). Value(&result), - // This doesn't have any effect in accessible mode. - // EchoMode(huh.EchoModePassword), ), ) From 519926b7cf7458df6e12d9f280ae6c2072796e22 Mon Sep 17 00:00:00 2001 From: Antonio Consuegra Date: Mon, 28 Apr 2025 13:54:09 +0200 Subject: [PATCH 056/249] Fix expected error output of TestRepo/repo-set-default --- acceptance/testdata/repo/repo-set-default.txtar | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/testdata/repo/repo-set-default.txtar b/acceptance/testdata/repo/repo-set-default.txtar index 4f7fa3273..de4eda11f 100644 --- a/acceptance/testdata/repo/repo-set-default.txtar +++ b/acceptance/testdata/repo/repo-set-default.txtar @@ -7,7 +7,7 @@ defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING # Ensure that no default is set cd $SCRIPT_NAME-$RANDOM_STRING exec gh repo set-default --view -stderr 'no default repository has been set; use `gh repo set-default` to select one' +stderr 'No default remote repository has been set. To learn more about the default repository, run: gh repo set-default --help' # Set the default exec gh repo set-default $ORG/$SCRIPT_NAME-$RANDOM_STRING From b0ab450a0697b1bced47716b5fee99ec0bbc5ea2 Mon Sep 17 00:00:00 2001 From: Antonio Consuegra Date: Mon, 28 Apr 2025 14:30:36 +0200 Subject: [PATCH 057/249] Fix expected error output of TestRepo/repo-rename-transfer-ownership --- acceptance/testdata/repo/repo-rename-transfer-ownership.txtar | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/testdata/repo/repo-rename-transfer-ownership.txtar b/acceptance/testdata/repo/repo-rename-transfer-ownership.txtar index 6b1a3e3fb..754a609d7 100644 --- a/acceptance/testdata/repo/repo-rename-transfer-ownership.txtar +++ b/acceptance/testdata/repo/repo-rename-transfer-ownership.txtar @@ -3,7 +3,7 @@ exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private # Attempt to rename the repo with a slash in the name ! exec gh repo rename $ORG/new-name --repo=$ORG/$SCRIPT_NAME-$RANDOM_STRING --yes -stderr '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 .' +stderr '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 .' # Defer repo deletion defer gh repo delete $ORG/$SCRIPT_NAME-$RANDOM_STRING --yes \ No newline at end of file From a53b6c074ce66b8df7dd7abddcc713abf15efa1b Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Mon, 28 Apr 2025 08:55:47 -0400 Subject: [PATCH 058/249] Assert password and auth token not displayed This commit expands existing tests (thanks to @babakks) to assert whether the echo mode is actually disabled for password and auth token prompts. --- internal/prompter/accessible_prompter_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 619eb14f1..63f253331 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -134,6 +134,11 @@ func TestAccessiblePrompter(t *testing.T) { passwordValue, err := p.Password("Enter password") require.NoError(t, err) require.Equal(t, dummyPassword, passwordValue) + + // Ensure the dummy password is not printed to the screen, + // asserting that echo mode is disabled without OS-level tests. + _, err = console.ExpectString(" \r\n\r\n") + require.NoError(t, err) }) t.Run("Confirm", func(t *testing.T) { @@ -192,6 +197,11 @@ func TestAccessiblePrompter(t *testing.T) { authValue, err := p.AuthToken() require.NoError(t, err) require.Equal(t, dummyAuthToken, authValue) + + // Ensure the dummy password is not printed to the screen, + // asserting that echo mode is disabled without OS-level tests. + _, err = console.ExpectString(" \r\n\r\n") + require.NoError(t, err) }) t.Run("AuthToken - blank input returns error", func(t *testing.T) { @@ -220,6 +230,11 @@ func TestAccessiblePrompter(t *testing.T) { authValue, err := p.AuthToken() require.NoError(t, err) require.Equal(t, dummyAuthTokenForAfterFailure, authValue) + + // Ensure the dummy password is not printed to the screen, + // asserting that echo mode is disabled without OS-level tests. + _, err = console.ExpectString(" \r\n\r\n") + require.NoError(t, err) }) t.Run("ConfirmDeletion", func(t *testing.T) { From 9fa00c350bb4a09dca6ed76d607ed8baef3d9d2e Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Mon, 28 Apr 2025 10:17:23 -0400 Subject: [PATCH 059/249] Update accessible tests based on huh@0.7.0 changes --- internal/prompter/accessible_prompter_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 63f253331..2211d7720 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -38,7 +38,7 @@ func TestAccessiblePrompter(t *testing.T) { go func() { // Wait for prompt to appear - _, err := console.ExpectString("Choose:") + _, err := console.ExpectString("Input a number between 1 and 3:") require.NoError(t, err) // Select option 1 @@ -57,7 +57,7 @@ func TestAccessiblePrompter(t *testing.T) { go func() { // Wait for prompt to appear - _, err := console.ExpectString("Select a number") + _, err := console.ExpectString("Input a number between 0 and 3:") require.NoError(t, err) // Select options 1 and 2 @@ -340,7 +340,7 @@ func TestAccessiblePrompter(t *testing.T) { require.NoError(t, err) // Expect a notice to enter something valid since blank is disallowed. - _, err = console.ExpectString("invalid input. please try again") + _, err = console.ExpectString("Invalid: must be between 1 and 1") require.NoError(t, err) // Send a 1 to select to open the editor. This will immediately exit @@ -367,7 +367,7 @@ func TestAccessiblePrompter(t *testing.T) { require.NoError(t, err) // Expect a notice to enter something valid since blank is disallowed. - _, err = console.ExpectString("invalid input. please try again") + _, err = console.ExpectString("Invalid: must be between 1 and 1") require.NoError(t, err) // Send a 1 to select to open the editor since skip is invalid and From 2d66877d6c447ffb20a602c338e6aab13c916035 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Mon, 28 Apr 2025 11:15:28 -0400 Subject: [PATCH 060/249] Update internal/prompter/accessible_prompter_test.go --- internal/prompter/accessible_prompter_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 2211d7720..3c8420cde 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -136,7 +136,7 @@ func TestAccessiblePrompter(t *testing.T) { require.Equal(t, dummyPassword, passwordValue) // Ensure the dummy password is not printed to the screen, - // asserting that echo mode is disabled without OS-level tests. + // asserting that echo mode is disabled. _, err = console.ExpectString(" \r\n\r\n") require.NoError(t, err) }) From df0aedbe3c0e5e474f7b5238354cf2ba22eecd3a Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Mon, 28 Apr 2025 11:16:35 -0400 Subject: [PATCH 061/249] Update internal/prompter/prompter.go --- 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 d56374665..c3281efbf 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -137,8 +137,8 @@ func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) func (p *accessiblePrompter) Password(prompt string) (string, error) { var result string - // EchoModeNone and EchoModePassword both result in disabling echo mode - // as password masking is outside of VT100 spec. + // EchoModePassword is not used as password masking is unsupported in huh. + // EchoModeNone and EchoModePassword have the same effect of hiding user input. form := p.newForm( huh.NewGroup( huh.NewInput(). From 88d52ebf97bcbfe48a064206709e0adcb0c92922 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Mon, 28 Apr 2025 11:20:17 -0400 Subject: [PATCH 062/249] Fix other disabled echo mode comments --- internal/prompter/accessible_prompter_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 3c8420cde..c95d379e3 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -199,7 +199,7 @@ func TestAccessiblePrompter(t *testing.T) { require.Equal(t, dummyAuthToken, authValue) // Ensure the dummy password is not printed to the screen, - // asserting that echo mode is disabled without OS-level tests. + // asserting that echo mode is disabled. _, err = console.ExpectString(" \r\n\r\n") require.NoError(t, err) }) @@ -232,7 +232,7 @@ func TestAccessiblePrompter(t *testing.T) { require.Equal(t, dummyAuthTokenForAfterFailure, authValue) // Ensure the dummy password is not printed to the screen, - // asserting that echo mode is disabled without OS-level tests. + // asserting that echo mode is disabled. _, err = console.ExpectString(" \r\n\r\n") require.NoError(t, err) }) From 9bb89de87c7fdeca18ccfc9b338e110c1ba676a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Apr 2025 15:44:19 +0000 Subject: [PATCH 063/249] chore(deps): bump actions/attest-build-provenance from 2.2.2 to 2.3.0 Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 2.2.2 to 2.3.0. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/bd77c077858b8d561b7a36cbe48ef4cc642ca39d...db473fddc028af60658334401dc6fa3ffd8669fd) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-version: 2.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/deployment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index a7b03f40d..850cc19b7 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -309,7 +309,7 @@ jobs: rpmsign --addsign dist/*.rpm - name: Attest release artifacts if: inputs.environment == 'production' - uses: actions/attest-build-provenance@bd77c077858b8d561b7a36cbe48ef4cc642ca39d # v2.2.2 + uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 with: subject-path: "dist/gh_*" - name: Run createrepo From d7e2468286db92630a0393386d717aec3c46f0fb Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Mon, 28 Apr 2025 15:01:15 -0400 Subject: [PATCH 064/249] Update a11y text based on draft feedback --- pkg/cmd/accessibility/accessibility.go | 89 +++++++++++++------------- pkg/cmd/root/help.go | 1 + 2 files changed, 44 insertions(+), 46 deletions(-) diff --git a/pkg/cmd/accessibility/accessibility.go b/pkg/cmd/accessibility/accessibility.go index 4992d488b..1d4a8009f 100644 --- a/pkg/cmd/accessibility/accessibility.go +++ b/pkg/cmd/accessibility/accessibility.go @@ -12,7 +12,7 @@ import ( ) const ( - communityURL = "https://github.com/orgs/community/discussions/categories/accessibility" + feedbackURL = "https://accessibility.github.com/feedback" ) type AccessibilityOptions struct { @@ -30,27 +30,27 @@ func NewCmdAccessibility(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "accessibility", Aliases: []string{"a11y"}, - Short: "Learn about GitHub CLI accessibility experience", + Short: "Learn about GitHub CLI accessibility experiences", Long: longDescription(opts.IO), Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { if opts.Web { if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(communityURL)) + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(feedbackURL)) } - return opts.Browser.Browse(communityURL) + return opts.Browser.Browse(feedbackURL) } return cmd.Help() }, Example: heredoc.Doc(` - # Open the GitHub Community Accessibility discussions in your browser + # Open the GitHub Accessibility site in your browser $ gh accessibility --web # Display color using customizable, 4-bit accessible colors $ gh config set accessible_colors enabled - # Display issue and pull request labels using RGB hex color codes in terminals that support 24-bit truecolor + # Display issue and pull request labels using RGB hex color codes in terminals that support 24-bit true color $ gh config set color_labels enabled # Use input prompts without redrawing the screen @@ -61,7 +61,7 @@ func NewCmdAccessibility(f *cmdutil.Factory) *cobra.Command { `), } - cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open the GitHub Community Accessibility discussions in the browser") + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open the GitHub Accessibility site in your browser") cmdutil.DisableAuthCheck(cmd) return cmd @@ -69,10 +69,11 @@ func NewCmdAccessibility(f *cmdutil.Factory) *cobra.Command { func longDescription(io *iostreams.IOStreams) string { cs := io.ColorScheme() - title := cs.Bold("LEARN ABOUT GITHUB CLI ACCESSIBILITY EFFORTS") - color := cs.Bold("CUSTOMIZABLE AND CONTRASTING COLORS") - prompter := cs.Bold("NON-INTERACTIVE USER INPUT PROMPTING") - spinner := cs.Bold("TEXT-BASED SPINNERS") + title := cs.Bold("Learn about GitHub CLI accessibility experiences") + color := cs.Bold("Customizable and contrasting colors") + prompter := cs.Bold("Non-interactive user input prompting") + spinner := cs.Bold("Text-based spinners") + feedback := cs.Bold("Join the conversation") return heredoc.Docf(` %[2]s @@ -81,70 +82,66 @@ func longDescription(io *iostreams.IOStreams) string { community and be empowered to contribute to the future of global software development with everything GitHub has to offer including the GitHub CLI. - We invite you to join us in improving GitHub CLI accessibility by sharing your - feedback and ideas in the GitHub Community Accessibility discussions: %[3]s + Text interfaces often use color for various purposes, but insufficient contrast + or customizability can leave some users unable to benefit. - %[4]s - - Color is a common approach to enhance user experiences, however users can find - themselves with a worse experience due to insufficient contrast or - customizability. - - To create an accessible experience, CLIs should use color palettes based on - terminal background appearance and limit colors to 4-bit ANSI color palettes, - which users can customize within terminal preferences. + To create a more accessible experience, the GitHub CLI will use color palettes + based on terminal background appearance and limit colors to 4-bit ANSI color + palettes, which users can customize within terminal preferences. With this new experience, the GitHub CLI provides multiple options to address color usage: - 1. The GitHub CLI will use 4-bit color palette for increased color contrast based on - dark and light backgrounds including rendering markdown based on GitHub Primer. + 1. The GitHub CLI will use 4-bit color palette for increased color contrast based + on dark and light backgrounds including rendering Markdown based on the + GitHub Primer design system. To enable this experience, use one of the following methods: - Run %[1]sgh config set accessible_colors enabled%[1]s - Set %[1]sGH_ACCESSIBLE_COLORS=enabled%[1]s environment variable 2. The GitHub CLI will display issue and pull request labels' custom RGB colors - in terminals with truecolor support. + in terminals with true color support. To enable this experience, use one of the following methods: - Run %[1]sgh config set color_labels enabled%[1]s - Set %[1]sGH_COLOR_LABELS=enabled%[1]s environment variable + %[4]s - %[5]s + Interactive text user interfaces manipulate the terminal cursor to redraw parts + of the screen, which can be difficult for speech synthesizers or braille displays + to accurately detect and read. - Interactive text user interfaces are an advanced approach to enhance user - experiences, which manipulate the terminal cursor to redraw parts of the screen. - However, this can be difficult for speech synthesizers or braille displays to - accurately detect and read. - - To create an accessible experience, CLIs should give users the ability to disable - this interactivity while providing a similar experience. - - With this new experience, the GitHub CLI will use non-interactive prompts for - user input. + To create a more accessible experience, the GitHub CLI gives users the ability to + disable this interactivity while providing a similar experience using + non-interactive prompts for user input. To enable this experience, use one of the following methods: - Run %[1]sgh config set accessible_prompter enabled%[1]s - Set %[1]sGH_ACCESSIBLE_PROMPTER=enabled%[1]s environment variable + %[5]s - %[6]s + Motion-based spinners communicate in-progress activity by manipulating the + terminal cursor to create a spinning effect, which can be difficult for users + with motion sensitivity or miscommunicate information to speech synthesizers. - Motion-based spinners are a common approach to communicate activity, which - manipulate the terminal cursor to create a spinning effect. However, this can be - difficult for users with motion sensitivity as well as speech synthesizers. - - To create an accessible experience, CLIs should give users the ability to disable - this interactivity while providing a similar experience. - - With this new experience, the GitHub CLI will use text-based progress indicators. + To create a more accessible experience, the GitHub CLI gives users the ability to + disable this interactivity while providing a similar experience using text-based + progress indicators. To enable this experience, use one of the following methods: - Run %[1]sgh config set spinner disabled%[1]s - Set %[1]sGH_SPINNER_DISABLED=yes%[1]s environment variable - `, "`", title, communityURL, color, prompter, spinner) + + %[6]s + + We invite you to join us in improving GitHub CLI accessibility by sharing your + feedback and ideas through GitHub Accessibility feedback channels: + + %[7]s + `, "`", title, color, prompter, spinner, feedback, feedbackURL) } diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go index ec6499f21..2676cdd15 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -190,6 +190,7 @@ func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) { Use %[1]sgh --help%[1]s for more information about a command. Read the manual at https://cli.github.com/manual Learn about exit codes using %[1]sgh help exit-codes%[1]s + Learn about accessibility experiences using %[1]sgh help accessibility%[1]s `, "`")}) out := f.IOStreams.Out From 9ed733fa5e751be1196f133b086e8981a835ee31 Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Tue, 29 Apr 2025 15:48:20 +0500 Subject: [PATCH 065/249] Add `closingIssuesReferences` JSON field to `pr view` (#10544) * [gh pr view] Support `closingIssuesReferences` JSON field * Support pagination * Support pagination * Fix typo * Add more fields --- api/export_pr.go | 19 +++++++++++ api/export_pr_test.go | 64 ++++++++++++++++++++++++++++++++++++ api/queries_pr.go | 22 +++++++++++++ api/query_builder.go | 22 +++++++++++++ pkg/cmd/pr/shared/finder.go | 44 +++++++++++++++++++++++++ pkg/cmd/pr/view/view_test.go | 1 + 6 files changed, 172 insertions(+) diff --git a/api/export_pr.go b/api/export_pr.go index bb3310811..7ae1a4ff4 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -139,6 +139,25 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} { } } data[f] = &requests + case "closingIssuesReferences": + items := make([]map[string]interface{}, 0, len(pr.ClosingIssuesReferences.Nodes)) + for _, n := range pr.ClosingIssuesReferences.Nodes { + items = append(items, map[string]interface{}{ + + "id": n.ID, + "number": n.Number, + "url": n.URL, + "repository": map[string]interface{}{ + "id": n.Repository.ID, + "name": n.Repository.Name, + "owner": map[string]interface{}{ + "id": n.Repository.Owner.ID, + "login": n.Repository.Owner.Login, + }, + }, + }) + } + data[f] = items default: sf := fieldByName(v, f) data[f] = sf.Interface() diff --git a/api/export_pr_test.go b/api/export_pr_test.go index b7f4dcddb..09a1dffe8 100644 --- a/api/export_pr_test.go +++ b/api/export_pr_test.go @@ -245,6 +245,70 @@ func TestPullRequest_ExportData(t *testing.T) { } `), }, + { + name: "linked issues", + fields: []string{"closingIssuesReferences"}, + inputJSON: heredoc.Doc(` + { "closingIssuesReferences": { "nodes": [ + { + "id": "I_123", + "number": 123, + "url": "https://github.com/cli/cli/issues/123", + "repository": { + "id": "R_123", + "name": "cli", + "owner": { + "id": "O_123", + "login": "cli" + } + } + }, + { + "id": "I_456", + "number": 456, + "url": "https://github.com/cli/cli/issues/456", + "repository": { + "id": "R_456", + "name": "cli", + "owner": { + "id": "O_456", + "login": "cli" + } + } + } + ] } } + `), + outputJSON: heredoc.Doc(` + { "closingIssuesReferences": [ + { + "id": "I_123", + "number": 123, + "repository": { + "id": "R_123", + "name": "cli", + "owner": { + "id": "O_123", + "login": "cli" + } + }, + "url": "https://github.com/cli/cli/issues/123" + }, + { + "id": "I_456", + "number": 456, + "repository": { + "id": "R_456", + "name": "cli", + "owner": { + "id": "O_456", + "login": "cli" + } + }, + "url": "https://github.com/cli/cli/issues/456" + } + ] } + `), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/api/queries_pr.go b/api/queries_pr.go index aa493b5e9..5b941bb42 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -93,6 +93,8 @@ type PullRequest struct { Reviews PullRequestReviews LatestReviews PullRequestReviews ReviewRequests ReviewRequests + + ClosingIssuesReferences ClosingIssuesReferences } type StatusCheckRollupNode struct { @@ -107,6 +109,26 @@ type CommitStatusCheckRollup struct { Contexts CheckContexts } +type ClosingIssuesReferences struct { + Nodes []struct { + ID string + Number int + URL string + Repository struct { + ID string + Name string + Owner struct { + ID string + Login string + } + } + } + PageInfo struct { + HasNextPage bool + EndCursor string + } +} + // https://docs.github.com/en/graphql/reference/enums#checkrunstate type CheckRunState string diff --git a/api/query_builder.go b/api/query_builder.go index 2112367e3..4c45da3c1 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -132,6 +132,25 @@ var prCommits = shortenQuery(` } `) +var prClosingIssuesReferences = shortenQuery(` + closingIssuesReferences(first: 100) { + nodes { + id, + number, + url, + repository { + id, + name, + owner { + id, + login + } + } + } + pageInfo{hasNextPage,endCursor} + } +`) + var autoMergeRequest = shortenQuery(` autoMergeRequest { authorEmail, @@ -287,6 +306,7 @@ var PullRequestFields = append(sharedIssuePRFields, "baseRefName", "baseRefOid", "changedFiles", + "closingIssuesReferences", "commits", "deletions", "files", @@ -366,6 +386,8 @@ func IssueGraphQL(fields []string) string { q = append(q, StatusCheckRollupGraphQLWithoutCountByState("")) case "statusCheckRollupWithCountByState": // pseudo-field q = append(q, StatusCheckRollupGraphQLWithCountByState()) + case "closingIssuesReferences": + q = append(q, prClosingIssuesReferences) default: q = append(q, field) } diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 6d36ef816..e6bb7d66a 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -239,6 +239,11 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err return preloadPrComments(httpClient, f.baseRefRepo, pr) }) } + if fields.Contains("closingIssuesReferences") { + g.Go(func() error { + return preloadPrClosingIssuesReferences(httpClient, f.baseRefRepo, pr) + }) + } if fields.Contains("statusCheckRollup") { g.Go(func() error { return preloadPrChecks(httpClient, f.baseRefRepo, pr) @@ -452,6 +457,45 @@ func preloadPrComments(client *http.Client, repo ghrepo.Interface, pr *api.PullR return nil } +func preloadPrClosingIssuesReferences(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error { + if !pr.ClosingIssuesReferences.PageInfo.HasNextPage { + return nil + } + + type response struct { + Node struct { + PullRequest struct { + ClosingIssuesReferences api.ClosingIssuesReferences `graphql:"closingIssuesReferences(first: 100, after: $endCursor)"` + } `graphql:"...on PullRequest"` + } `graphql:"node(id: $id)"` + } + + variables := map[string]interface{}{ + "id": githubv4.ID(pr.ID), + "endCursor": githubv4.String(pr.ClosingIssuesReferences.PageInfo.EndCursor), + } + + gql := api.NewClientFromHTTP(client) + + for { + var query response + err := gql.Query(repo.RepoHost(), "closingIssuesReferences", &query, variables) + if err != nil { + return err + } + + pr.ClosingIssuesReferences.Nodes = append(pr.ClosingIssuesReferences.Nodes, query.Node.PullRequest.ClosingIssuesReferences.Nodes...) + + if !query.Node.PullRequest.ClosingIssuesReferences.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Node.PullRequest.ClosingIssuesReferences.PageInfo.EndCursor) + } + + pr.ClosingIssuesReferences.PageInfo.HasNextPage = false + return nil +} + func preloadPrChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error { if len(pr.StatusCheckRollup.Nodes) == 0 { return nil diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go index e7f572c76..2cd4066b8 100644 --- a/pkg/cmd/pr/view/view_test.go +++ b/pkg/cmd/pr/view/view_test.go @@ -37,6 +37,7 @@ func TestJSONFields(t *testing.T) { "changedFiles", "closed", "closedAt", + "closingIssuesReferences", "comments", "commits", "createdAt", From 692bdaf5784ecc326deb78089a077b8e2c4ddf07 Mon Sep 17 00:00:00 2001 From: Barak Amar Date: Tue, 29 Apr 2025 14:32:51 +0300 Subject: [PATCH 066/249] Apply code review changes --- pkg/cmd/pr/shared/finder.go | 10 ++++++++-- pkg/cmd/pr/shared/finder_test.go | 4 +--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 9e92c0692..a87d6790f 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -212,7 +212,8 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err } var pr *api.PullRequest - if f.prNumber > 0 || f.branchName == "" { + if f.prNumber > 0 { + // If we have a PR number, let's look it up if numberFieldOnly { // avoid hitting the API if we already have all the information return &api.PullRequest{Number: f.prNumber}, f.baseRefRepo, nil @@ -221,11 +222,16 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err if err != nil { return pr, f.baseRefRepo, err } - } else { + } else if prRefs.BaseRepo() != nil && f.branchName != "" { + // No PR number, but we have a base repo and branch name. pr, err = findForRefs(httpClient, prRefs, opts.States, fields.ToSlice()) if err != nil { return pr, f.baseRefRepo, err } + } else { + // If we don't have a PR number or a base repo and branch name, + // we can't do anything + return nil, f.baseRefRepo, &NotFoundError{fmt.Errorf("no pull requests found")} } g, _ := errgroup.WithContext(context.Background()) diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 66fb900eb..abc754d1a 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -180,9 +180,7 @@ func TestFind(t *testing.T) { remotePushDefaultFn: stubRemotePushDefault("", nil), }, }, - httpStub: nil, - wantPR: 0, - wantRepo: "https://github.com/ORIGINOWNER/REPO", + wantErr: true, }, { name: "number with hash argument", From d8512a90666afc2e247506777b3319b2ddb820e8 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 29 Apr 2025 16:35:04 -0600 Subject: [PATCH 067/249] fix(prompter): respect default MultiSelect a11y prompter --- internal/prompter/accessible_prompter_test.go | 21 +++++++++++++++++++ internal/prompter/prompter.go | 9 ++++++++ 2 files changed, 30 insertions(+) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 619eb14f1..a7326752d 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -76,6 +76,27 @@ func TestAccessiblePrompter(t *testing.T) { assert.Equal(t, []int{0, 1}, multiSelectValue) }) + t.Run("MultiSelect - default values are respected by being pre-selected", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select a number") + require.NoError(t, err) + + // Don't select anything because the default should be selected. + + // This confirms selections + _, err = console.SendLine("0") + require.NoError(t, err) + }() + + multiSelectValue, err := p.MultiSelect("Select a number", []string{"2"}, []string{"1", "2", "3"}) + require.NoError(t, err) + assert.Equal(t, []int{1}, multiSelectValue) + }) + t.Run("Input", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 2a4328366..322270086 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -2,6 +2,7 @@ package prompter import ( "fmt" + "slices" "strings" "github.com/AlecAivazis/survey/v2" @@ -100,6 +101,14 @@ func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, optio var result []int formOptions := make([]huh.Option[int], len(options)) for i, o := range options { + // If this option is in the defaults slice, + // let's add it's index to the result slice and huh + // will treat it as a default selection. + // TODO: does an invalid default value constitute a panic? + if slices.Contains(defaults, o) { + result = append(result, i) + } + formOptions[i] = huh.NewOption(o, i) } From 00c930d50957c1bededbf6a40b243be4e5a71bab Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 30 Apr 2025 08:04:16 -0600 Subject: [PATCH 068/249] doc(prompter): small typo --- 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 2c5c221b0..1e4f5592a 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -102,7 +102,7 @@ func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, optio formOptions := make([]huh.Option[int], len(options)) for i, o := range options { // If this option is in the defaults slice, - // let's add it's index to the result slice and huh + // let's add its index to the result slice and huh // will treat it as a default selection. // TODO: does an invalid default value constitute a panic? if slices.Contains(defaults, o) { From 096106a3d703ce9c8177b6608b58f6338f5478f0 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Wed, 30 Apr 2025 14:20:16 -0400 Subject: [PATCH 069/249] Apply suggestions from code review Co-authored-by: Melissa Xie --- pkg/cmd/accessibility/accessibility.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/accessibility/accessibility.go b/pkg/cmd/accessibility/accessibility.go index 1d4a8009f..f05bc7bcc 100644 --- a/pkg/cmd/accessibility/accessibility.go +++ b/pkg/cmd/accessibility/accessibility.go @@ -30,7 +30,7 @@ func NewCmdAccessibility(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "accessibility", Aliases: []string{"a11y"}, - Short: "Learn about GitHub CLI accessibility experiences", + Short: "Learn about GitHub CLI's accessibility experiences", Long: longDescription(opts.IO), Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -87,7 +87,7 @@ func longDescription(io *iostreams.IOStreams) string { Text interfaces often use color for various purposes, but insufficient contrast or customizability can leave some users unable to benefit. - To create a more accessible experience, the GitHub CLI will use color palettes + For a more accessible experience, the GitHub CLI can use color palettes based on terminal background appearance and limit colors to 4-bit ANSI color palettes, which users can customize within terminal preferences. @@ -115,8 +115,7 @@ func longDescription(io *iostreams.IOStreams) string { of the screen, which can be difficult for speech synthesizers or braille displays to accurately detect and read. - To create a more accessible experience, the GitHub CLI gives users the ability to - disable this interactivity while providing a similar experience using + For a more accessible experience, the GitHub CLI can provide a similar experience using non-interactive prompts for user input. To enable this experience, use one of the following methods: @@ -126,12 +125,11 @@ func longDescription(io *iostreams.IOStreams) string { %[5]s Motion-based spinners communicate in-progress activity by manipulating the - terminal cursor to create a spinning effect, which can be difficult for users + terminal cursor to create a spinning effect, which may cause discomfort to users with motion sensitivity or miscommunicate information to speech synthesizers. - To create a more accessible experience, the GitHub CLI gives users the ability to - disable this interactivity while providing a similar experience using text-based - progress indicators. + For a more accessible experience, this interactivity can be disabled in favor + of text-based progress indicators. To enable this experience, use one of the following methods: - Run %[1]sgh config set spinner disabled%[1]s From 2fd1a45a81ae6466dea5598ce9825f984a6fb770 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Wed, 30 Apr 2025 14:21:02 -0400 Subject: [PATCH 070/249] Update pkg/cmd/accessibility/accessibility.go --- pkg/cmd/accessibility/accessibility.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/accessibility/accessibility.go b/pkg/cmd/accessibility/accessibility.go index f05bc7bcc..d37631d25 100644 --- a/pkg/cmd/accessibility/accessibility.go +++ b/pkg/cmd/accessibility/accessibility.go @@ -69,7 +69,7 @@ func NewCmdAccessibility(f *cmdutil.Factory) *cobra.Command { func longDescription(io *iostreams.IOStreams) string { cs := io.ColorScheme() - title := cs.Bold("Learn about GitHub CLI accessibility experiences") + title := cs.Bold("Learn about GitHub CLI's accessibility experiences") color := cs.Bold("Customizable and contrasting colors") prompter := cs.Bold("Non-interactive user input prompting") spinner := cs.Bold("Text-based spinners") From c20138d8442457c5c6f326fdbd13d73e47b06a32 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Wed, 30 Apr 2025 14:35:35 -0400 Subject: [PATCH 071/249] Update pkg/cmd/accessibility/accessibility.go --- pkg/cmd/accessibility/accessibility.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/pkg/cmd/accessibility/accessibility.go b/pkg/cmd/accessibility/accessibility.go index d37631d25..19fb813a4 100644 --- a/pkg/cmd/accessibility/accessibility.go +++ b/pkg/cmd/accessibility/accessibility.go @@ -50,9 +50,6 @@ func NewCmdAccessibility(f *cmdutil.Factory) *cobra.Command { # Display color using customizable, 4-bit accessible colors $ gh config set accessible_colors enabled - # Display issue and pull request labels using RGB hex color codes in terminals that support 24-bit true color - $ gh config set color_labels enabled - # Use input prompts without redrawing the screen $ gh config set accessible_prompter enabled From 830335d9209d399ae1415ec927dd4d00ea1e0ab2 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Wed, 30 Apr 2025 15:05:07 -0400 Subject: [PATCH 072/249] PR feedback --- pkg/cmd/accessibility/accessibility.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/accessibility/accessibility.go b/pkg/cmd/accessibility/accessibility.go index 19fb813a4..c5de6c1a4 100644 --- a/pkg/cmd/accessibility/accessibility.go +++ b/pkg/cmd/accessibility/accessibility.go @@ -12,7 +12,7 @@ import ( ) const ( - feedbackURL = "https://accessibility.github.com/feedback" + webURL = "https://accessibility.github.com/conformance/cli/" ) type AccessibilityOptions struct { @@ -36,9 +36,9 @@ func NewCmdAccessibility(f *cmdutil.Factory) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { if opts.Web { if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(feedbackURL)) + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL)) } - return opts.Browser.Browse(feedbackURL) + return opts.Browser.Browse(webURL) } return cmd.Help() @@ -125,7 +125,7 @@ func longDescription(io *iostreams.IOStreams) string { terminal cursor to create a spinning effect, which may cause discomfort to users with motion sensitivity or miscommunicate information to speech synthesizers. - For a more accessible experience, this interactivity can be disabled in favor + For a more accessible experience, this interactivity can be disabled in favor of text-based progress indicators. To enable this experience, use one of the following methods: @@ -138,5 +138,5 @@ func longDescription(io *iostreams.IOStreams) string { feedback and ideas through GitHub Accessibility feedback channels: %[7]s - `, "`", title, color, prompter, spinner, feedback, feedbackURL) + `, "`", title, color, prompter, spinner, feedback, webURL) } From 0a1e7a1fdc68e2824605f0c4c6dd4dbcad448888 Mon Sep 17 00:00:00 2001 From: "Sinan Sonmez (Chaush)" <37421564+sinansonmez@users.noreply.github.com> Date: Thu, 1 May 2025 15:12:55 +0200 Subject: [PATCH 073/249] Add `--delete-last` option to `pr comment` and `issue comment` (#10596) * deletion for issues with confirmation flag * add handling for interaction case * finish implementation for issues * finish the implementation for issues * finalize the implementation for PR * fix missing --yes flag for PR * address PR comments related to feedbacks * improve CommentablePreRun for pre checks * improve confirmation prompt and truncate long comment body * address PR comments on tests * Truncate comment for confirmation prompt Signed-off-by: Babak K. Shandiz * Improve test case descriptions Signed-off-by: Babak K. Shandiz * Fix mock comment body Signed-off-by: Babak K. Shandiz * Remove irrelevant prompt stub Signed-off-by: Babak K. Shandiz * Use `opts.Interactive` as TTY indicator Signed-off-by: Babak K. Shandiz * Fix expected `Interactive` value Signed-off-by: Babak K. Shandiz * Polish `TestNewCmdComment` Signed-off-by: Babak K. Shandiz --------- Signed-off-by: Babak K. Shandiz Co-authored-by: Babak K. Shandiz --- api/queries_comments.go | 25 +++ pkg/cmd/issue/comment/comment.go | 7 +- pkg/cmd/issue/comment/comment_test.go | 218 ++++++++++++++++++++++++- pkg/cmd/pr/comment/comment.go | 7 +- pkg/cmd/pr/comment/comment_test.go | 220 +++++++++++++++++++++++++- pkg/cmd/pr/shared/commentable.go | 75 +++++++++ 6 files changed, 542 insertions(+), 10 deletions(-) diff --git a/api/queries_comments.go b/api/queries_comments.go index 5cc84a3e4..8af17fd2a 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -44,6 +44,10 @@ type CommentCreateInput struct { SubjectId string } +type CommentDeleteInput struct { + CommentId string +} + type CommentUpdateInput struct { Body string CommentId string @@ -99,6 +103,27 @@ func CommentUpdate(client *Client, repoHost string, params CommentUpdateInput) ( return mutation.UpdateIssueComment.IssueComment.URL, nil } +func CommentDelete(client *Client, repoHost string, params CommentDeleteInput) error { + var mutation struct { + DeleteIssueComment struct { + ClientMutationID string + } `graphql:"deleteIssueComment(input: $input)"` + } + + variables := map[string]interface{}{ + "input": githubv4.DeleteIssueCommentInput{ + ID: githubv4.ID(params.CommentId), + }, + } + + err := client.Mutate(repoHost, "CommentDelete", &mutation, variables) + if err != nil { + return err + } + + return nil +} + func (c Comment) Identifier() string { return c.ID } diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index 706ff791e..9b7791656 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -18,6 +18,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e InteractiveEditSurvey: prShared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams), ConfirmSubmitSurvey: prShared.CommentableConfirmSubmitSurvey(f.Prompter), ConfirmCreateIfNoneSurvey: prShared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter), + ConfirmDeleteLastComment: prShared.CommentableConfirmDeleteLastComment(f.Prompter), OpenInBrowser: f.Browser.Browse, } @@ -63,7 +64,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e } fields := []string{"id", "url"} - if opts.EditLast { + if opts.EditLast || opts.DeleteLast { fields = append(fields, "comments") } @@ -96,7 +97,9 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)") 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.EditLast, "edit-last", false, "Edit the last comment of the current user") + cmd.Flags().BoolVar(&opts.DeleteLast, "delete-last", false, "Delete the last comment of the current user") + cmd.Flags().BoolVar(&opts.DeleteLastConfirmed, "yes", false, "Skip the delete confirmation prompt when --delete-last is provided") cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last") return cmd diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index 794dafda4..adee53f7e 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -2,6 +2,7 @@ package comment import ( "bytes" + "errors" "fmt" "net/http" "os" @@ -31,11 +32,13 @@ func TestNewCmdComment(t *testing.T) { stdin string output shared.CommentableOptions wantsErr bool + isTTY bool }{ { name: "no arguments", input: "", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, { @@ -46,6 +49,7 @@ func TestNewCmdComment(t *testing.T) { InputType: 0, Body: "", }, + isTTY: true, wantsErr: false, }, { @@ -56,6 +60,7 @@ func TestNewCmdComment(t *testing.T) { InputType: 0, Body: "", }, + isTTY: true, wantsErr: false, }, { @@ -66,6 +71,7 @@ func TestNewCmdComment(t *testing.T) { InputType: shared.InputTypeInline, Body: "test", }, + isTTY: true, wantsErr: false, }, { @@ -77,6 +83,7 @@ func TestNewCmdComment(t *testing.T) { InputType: shared.InputTypeInline, Body: "this is on standard input", }, + isTTY: true, wantsErr: false, }, { @@ -87,6 +94,7 @@ func TestNewCmdComment(t *testing.T) { InputType: shared.InputTypeInline, Body: "a body from file", }, + isTTY: true, wantsErr: false, }, { @@ -97,6 +105,7 @@ func TestNewCmdComment(t *testing.T) { InputType: shared.InputTypeEditor, Body: "", }, + isTTY: true, wantsErr: false, }, { @@ -107,6 +116,7 @@ func TestNewCmdComment(t *testing.T) { InputType: shared.InputTypeWeb, Body: "", }, + isTTY: true, wantsErr: false, }, { @@ -118,6 +128,7 @@ func TestNewCmdComment(t *testing.T) { Body: "", EditLast: true, }, + isTTY: true, wantsErr: false, }, { @@ -130,42 +141,110 @@ func TestNewCmdComment(t *testing.T) { EditLast: true, CreateIfNone: true, }, + isTTY: true, wantsErr: false, }, + { + name: "delete last flag non-interactive", + input: "1 --delete-last", + isTTY: false, + wantsErr: true, + }, + { + name: "delete last flag and pre-confirmation non-interactive", + input: "1 --delete-last --yes", + output: shared.CommentableOptions{ + DeleteLast: true, + DeleteLastConfirmed: true, + }, + isTTY: false, + wantsErr: false, + }, + { + name: "delete last flag interactive", + input: "1 --delete-last", + output: shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + }, + isTTY: true, + wantsErr: false, + }, + { + name: "delete last flag and pre-confirmation interactive", + input: "1 --delete-last --yes", + output: shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + DeleteLastConfirmed: true, + }, + isTTY: true, + wantsErr: false, + }, + { + name: "delete last flag and pre-confirmation with web flag", + input: "1 --delete-last --yes --web", + isTTY: true, + wantsErr: true, + }, + { + name: "delete last flag and pre-confirmation with editor flag", + input: "1 --delete-last --yes --editor", + isTTY: true, + wantsErr: true, + }, + { + name: "delete last flag and pre-confirmation with body flag", + input: "1 --delete-last --yes --body", + isTTY: true, + wantsErr: true, + }, + { + name: "delete pre-confirmation without delete last flag", + input: "1 --yes", + isTTY: true, + wantsErr: true, + }, { name: "body and body-file flags", input: "1 --body 'test' --body-file 'test-file.txt'", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, { name: "editor and web flags", input: "1 --editor --web", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, { name: "editor and body flags", input: "1 --editor --body test", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, { name: "web and body flags", input: "1 --web --body test", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, { name: "editor, web, and body flags", input: "1 --editor --web --body test", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, { name: "create-if-none flag without edit-last", input: "1 --create-if-none", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, } @@ -173,9 +252,10 @@ func TestNewCmdComment(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, stdin, _, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) + isTTY := tt.isTTY + ios.SetStdoutTTY(isTTY) + ios.SetStdinTTY(isTTY) + ios.SetStderrTTY(isTTY) if tt.stdin != "" { _, _ = stdin.WriteString(tt.stdin) @@ -211,6 +291,8 @@ func TestNewCmdComment(t *testing.T) { assert.Equal(t, tt.output.Interactive, gotOpts.Interactive) assert.Equal(t, tt.output.InputType, gotOpts.InputType) assert.Equal(t, tt.output.Body, gotOpts.Body) + assert.Equal(t, tt.output.DeleteLast, gotOpts.DeleteLast) + assert.Equal(t, tt.output.DeleteLastConfirmed, gotOpts.DeleteLastConfirmed) }) } } @@ -220,6 +302,7 @@ func Test_commentRun(t *testing.T) { name string input *shared.CommentableOptions emptyComments bool + comments api.Comments httpStubs func(*testing.T, *httpmock.Registry) stdout string stderr string @@ -255,6 +338,7 @@ func Test_commentRun(t *testing.T) { }, emptyComments: true, wantsErr: true, + stdout: "no comments found for current user", }, { name: "updating last comment with interactive editor succeeds if there are comments", @@ -331,6 +415,7 @@ func Test_commentRun(t *testing.T) { }, emptyComments: true, wantsErr: true, + stdout: "no comments found for current user", }, { name: "creating new comment with non-interactive editor succeeds", @@ -358,6 +443,7 @@ func Test_commentRun(t *testing.T) { }, emptyComments: true, wantsErr: true, + stdout: "no comments found for current user", }, { name: "updating last comment with non-interactive editor succeeds if there are comments", @@ -433,6 +519,117 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n", }, + { + name: "deleting last comment non-interactively without any comment", + input: &shared.CommentableOptions{ + Interactive: false, + DeleteLast: true, + }, + emptyComments: true, + wantsErr: true, + stdout: "no comments found for current user", + }, + { + name: "deleting last comment interactively without any comment", + input: &shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + }, + emptyComments: true, + wantsErr: true, + stdout: "no comments found for current user", + }, + { + name: "deleting last comment non-interactively and pre-confirmed", + input: &shared.CommentableOptions{ + Interactive: false, + DeleteLast: true, + DeleteLastConfirmed: true, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentDelete(t, reg) + }, + stderr: "Comment deleted\n", + }, + { + name: "deleting last comment interactively and pre-confirmed", + input: &shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + DeleteLastConfirmed: true, + }, + 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, Body: "comment body"}, + }}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentDelete(t, reg) + }, + stderr: "Comment deleted\n", + }, + { + name: "deleting last comment interactively and confirmed", + input: &shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + + ConfirmDeleteLastComment: func(body string) (bool, error) { + if body != "comment body" { + return false, errors.New("unexpected comment body") + } + return true, nil + }, + }, + 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, Body: "comment body"}, + }}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentDelete(t, reg) + }, + stdout: "! Deleted comments cannot be recovered.\n", + stderr: "Comment deleted\n", + }, + { + name: "deleting last comment interactively and confirmation declined", + input: &shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + + ConfirmDeleteLastComment: func(body string) (bool, error) { + if body != "comment body" { + return false, errors.New("unexpected comment body") + } + return true, nil + }, + }, + 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, Body: "comment body"}, + }}, + wantsErr: true, + stdout: "deletion not confirmed", + }, + { + name: "deleting last comment interactively and confirmed with long comment body", + input: &shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + + ConfirmDeleteLastComment: func(body string) (bool, error) { + if body != "Lorem ipsum dolor sit amet, consectet lo..." { + return false, errors.New("unexpected comment body") + } + return true, nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentDelete(t, reg) + }, + 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, Body: "Lorem ipsum dolor sit amet, consectet lorem ipsum again"}, + }}, + wantsErr: false, + stdout: "! Deleted comments cannot be recovered.\n", + stderr: "Comment deleted\n", + }, } for _, tt := range tests { ios, _, stdout, stderr := iostreams.Test() @@ -458,6 +655,8 @@ func Test_commentRun(t *testing.T) { if tt.emptyComments { comments.Nodes = []api.Comment{} + } else if len(tt.comments.Nodes) > 0 { + comments = tt.comments } tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) { @@ -472,6 +671,7 @@ func Test_commentRun(t *testing.T) { err := shared.CommentableRun(tt.input) if tt.wantsErr { assert.Error(t, err) + assert.Equal(t, tt.stderr, stderr.String()) return } assert.NoError(t, err) @@ -508,3 +708,15 @@ func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) { }), ) } + +func mockCommentDelete(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation CommentDelete\b`), + httpmock.GraphQLMutation(` + { "data": { "deleteIssueComment": {} } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "id1", inputs["id"]) + }, + ), + ) +} diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go index a2ab4bf9e..2eed7d353 100644 --- a/pkg/cmd/pr/comment/comment.go +++ b/pkg/cmd/pr/comment/comment.go @@ -16,6 +16,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err InteractiveEditSurvey: shared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams), ConfirmSubmitSurvey: shared.CommentableConfirmSubmitSurvey(f.Prompter), ConfirmCreateIfNoneSurvey: shared.CommentableInteractiveCreateIfNoneSurvey(f.Prompter), + ConfirmDeleteLastComment: shared.CommentableConfirmDeleteLastComment(f.Prompter), OpenInBrowser: f.Browser.Browse, } @@ -43,7 +44,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err selector = args[0] } fields := []string{"id", "url"} - if opts.EditLast { + if opts.EditLast || opts.DeleteLast { fields = append(fields, "comments") } finder := shared.NewFinder(f) @@ -75,7 +76,9 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)") 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.EditLast, "edit-last", false, "Edit the last comment of the current user") + cmd.Flags().BoolVar(&opts.DeleteLast, "delete-last", false, "Delete the last comment of the current user") + cmd.Flags().BoolVar(&opts.DeleteLastConfirmed, "yes", false, "Skip the delete confirmation prompt when --delete-last is provided") cmd.Flags().BoolVar(&opts.CreateIfNone, "create-if-none", false, "Create a new comment if no comments are found. Can be used only with --edit-last") return cmd diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go index 0941f2533..b9d8e153d 100644 --- a/pkg/cmd/pr/comment/comment_test.go +++ b/pkg/cmd/pr/comment/comment_test.go @@ -2,6 +2,7 @@ package comment import ( "bytes" + "errors" "fmt" "net/http" "os" @@ -31,6 +32,7 @@ func TestNewCmdComment(t *testing.T) { stdin string output shared.CommentableOptions wantsErr bool + isTTY bool }{ { name: "no arguments", @@ -40,12 +42,14 @@ func TestNewCmdComment(t *testing.T) { InputType: 0, Body: "", }, + isTTY: true, wantsErr: false, }, { name: "two arguments", input: "1 2", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, { @@ -56,6 +60,7 @@ func TestNewCmdComment(t *testing.T) { InputType: 0, Body: "", }, + isTTY: true, wantsErr: false, }, { @@ -66,6 +71,7 @@ func TestNewCmdComment(t *testing.T) { InputType: 0, Body: "", }, + isTTY: true, wantsErr: false, }, { @@ -76,6 +82,7 @@ func TestNewCmdComment(t *testing.T) { InputType: 0, Body: "", }, + isTTY: true, wantsErr: false, }, { @@ -86,6 +93,7 @@ func TestNewCmdComment(t *testing.T) { InputType: shared.InputTypeInline, Body: "test", }, + isTTY: true, wantsErr: false, }, { @@ -97,6 +105,7 @@ func TestNewCmdComment(t *testing.T) { InputType: shared.InputTypeInline, Body: "this is on standard input", }, + isTTY: true, wantsErr: false, }, { @@ -107,6 +116,7 @@ func TestNewCmdComment(t *testing.T) { InputType: shared.InputTypeInline, Body: "a body from file", }, + isTTY: true, wantsErr: false, }, { @@ -117,6 +127,7 @@ func TestNewCmdComment(t *testing.T) { InputType: shared.InputTypeEditor, Body: "", }, + isTTY: true, wantsErr: false, }, { @@ -127,6 +138,7 @@ func TestNewCmdComment(t *testing.T) { InputType: shared.InputTypeWeb, Body: "", }, + isTTY: true, wantsErr: false, }, { @@ -138,6 +150,7 @@ func TestNewCmdComment(t *testing.T) { Body: "", EditLast: true, }, + isTTY: true, wantsErr: false, }, { @@ -150,42 +163,110 @@ func TestNewCmdComment(t *testing.T) { EditLast: true, CreateIfNone: true, }, + isTTY: true, wantsErr: false, }, + { + name: "delete last flag non-interactive", + input: "1 --delete-last", + isTTY: false, + wantsErr: true, + }, + { + name: "delete last flag and pre-confirmation non-interactive", + input: "1 --delete-last --yes", + output: shared.CommentableOptions{ + DeleteLast: true, + DeleteLastConfirmed: true, + }, + isTTY: false, + wantsErr: false, + }, + { + name: "delete last flag interactive", + input: "1 --delete-last", + output: shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + }, + isTTY: true, + wantsErr: false, + }, + { + name: "delete last flag and pre-confirmation interactive", + input: "1 --delete-last --yes", + output: shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + DeleteLastConfirmed: true, + }, + isTTY: true, + wantsErr: false, + }, + { + name: "delete last flag and pre-confirmation with web flag", + input: "1 --delete-last --yes --web", + isTTY: true, + wantsErr: true, + }, + { + name: "delete last flag and pre-confirmation with editor flag", + input: "1 --delete-last --yes --editor", + isTTY: true, + wantsErr: true, + }, + { + name: "delete last flag and pre-confirmation with body flag", + input: "1 --delete-last --yes --body", + isTTY: true, + wantsErr: true, + }, + { + name: "delete pre-confirmation without delete last flag", + input: "1 --yes", + isTTY: true, + wantsErr: true, + }, { name: "body and body-file flags", input: "1 --body 'test' --body-file 'test-file.txt'", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, { name: "editor and web flags", input: "1 --editor --web", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, { name: "editor and body flags", input: "1 --editor --body test", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, { name: "web and body flags", input: "1 --web --body test", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, { name: "editor, web, and body flags", input: "1 --editor --web --body test", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, { name: "create-if-none flag without edit-last", input: "1 --create-if-none", output: shared.CommentableOptions{}, + isTTY: true, wantsErr: true, }, } @@ -193,9 +274,10 @@ func TestNewCmdComment(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, stdin, _, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) + isTTY := tt.isTTY + ios.SetStdoutTTY(isTTY) + ios.SetStdinTTY(isTTY) + ios.SetStderrTTY(isTTY) if tt.stdin != "" { _, _ = stdin.WriteString(tt.stdin) @@ -231,6 +313,8 @@ func TestNewCmdComment(t *testing.T) { assert.Equal(t, tt.output.Interactive, gotOpts.Interactive) assert.Equal(t, tt.output.InputType, gotOpts.InputType) assert.Equal(t, tt.output.Body, gotOpts.Body) + assert.Equal(t, tt.output.DeleteLast, gotOpts.DeleteLast) + assert.Equal(t, tt.output.DeleteLastConfirmed, gotOpts.DeleteLastConfirmed) }) } } @@ -240,6 +324,7 @@ func Test_commentRun(t *testing.T) { name string input *shared.CommentableOptions emptyComments bool + comments api.Comments httpStubs func(*testing.T, *httpmock.Registry) stdout string stderr string @@ -274,6 +359,7 @@ func Test_commentRun(t *testing.T) { }, emptyComments: true, wantsErr: true, + stdout: "no comments found for current user", }, { name: "updating last comment with interactive editor succeeds if there are comments", @@ -350,6 +436,7 @@ func Test_commentRun(t *testing.T) { }, emptyComments: true, wantsErr: true, + stdout: "no comments found for current user", }, { name: "creating new comment with non-interactive editor succeeds", @@ -377,6 +464,7 @@ func Test_commentRun(t *testing.T) { }, emptyComments: true, wantsErr: true, + stdout: "no comments found for current user", }, { name: "updating last comment with non-interactive editor succeeds if there are comments", @@ -451,6 +539,117 @@ func Test_commentRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n", }, + { + name: "deleting last comment non-interactively without any comment", + input: &shared.CommentableOptions{ + Interactive: false, + DeleteLast: true, + }, + emptyComments: true, + wantsErr: true, + stdout: "no comments found for current user", + }, + { + name: "deleting last comment interactively without any comment", + input: &shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + }, + emptyComments: true, + wantsErr: true, + stdout: "no comments found for current user", + }, + { + name: "deleting last comment non-interactively and pre-confirmed", + input: &shared.CommentableOptions{ + Interactive: false, + DeleteLast: true, + DeleteLastConfirmed: true, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentDelete(t, reg) + }, + stderr: "Comment deleted\n", + }, + { + name: "deleting last comment interactively and pre-confirmed", + input: &shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + DeleteLastConfirmed: true, + }, + 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, Body: "comment body"}, + }}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentDelete(t, reg) + }, + stderr: "Comment deleted\n", + }, + { + name: "deleting last comment interactively and confirmed", + input: &shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + + ConfirmDeleteLastComment: func(body string) (bool, error) { + if body != "comment body" { + return false, errors.New("unexpected comment body") + } + return true, nil + }, + }, + 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, Body: "comment body"}, + }}, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentDelete(t, reg) + }, + stdout: "! Deleted comments cannot be recovered.\n", + stderr: "Comment deleted\n", + }, + { + name: "deleting last comment interactively and confirmation declined", + input: &shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + + ConfirmDeleteLastComment: func(body string) (bool, error) { + if body != "comment body" { + return false, errors.New("unexpected comment body") + } + return true, nil + }, + }, + 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, Body: "comment body"}, + }}, + wantsErr: true, + stdout: "deletion not confirmed", + }, + { + name: "deleting last comment interactively and confirmed with long comment body", + input: &shared.CommentableOptions{ + Interactive: true, + DeleteLast: true, + + ConfirmDeleteLastComment: func(body string) (bool, error) { + if body != "Lorem ipsum dolor sit amet, consectet lo..." { + return false, errors.New("unexpected comment body") + } + return true, nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockCommentDelete(t, reg) + }, + 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, Body: "Lorem ipsum dolor sit amet, consectet lorem ipsum again"}, + }}, + wantsErr: false, + stdout: "! Deleted comments cannot be recovered.\n", + stderr: "Comment deleted\n", + }, } for _, tt := range tests { ios, _, stdout, stderr := iostreams.Test() @@ -475,6 +674,8 @@ func Test_commentRun(t *testing.T) { }} if tt.emptyComments { comments.Nodes = []api.Comment{} + } else if len(tt.comments.Nodes) > 0 { + comments = tt.comments } tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) { @@ -489,6 +690,7 @@ func Test_commentRun(t *testing.T) { err := shared.CommentableRun(tt.input) if tt.wantsErr { assert.Error(t, err) + assert.Equal(t, tt.stderr, stderr.String()) return } assert.NoError(t, err) @@ -524,3 +726,15 @@ func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) { }), ) } + +func mockCommentDelete(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation CommentDelete\b`), + httpmock.GraphQLMutation(` + { "data": { "deleteIssueComment": {} } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "id1", inputs["id"]) + }, + ), + ) +} diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go index f909c7559..015d84a4b 100644 --- a/pkg/cmd/pr/shared/commentable.go +++ b/pkg/cmd/pr/shared/commentable.go @@ -18,6 +18,7 @@ import ( ) var errNoUserComments = errors.New("no comments found for current user") +var errDeleteNotConfirmed = errors.New("deletion not confirmed") type InputType int @@ -41,11 +42,14 @@ type CommentableOptions struct { InteractiveEditSurvey func(string) (string, error) ConfirmSubmitSurvey func() (bool, error) ConfirmCreateIfNoneSurvey func() (bool, error) + ConfirmDeleteLastComment func(string) (bool, error) OpenInBrowser func(string) error Interactive bool InputType InputType Body string EditLast bool + DeleteLast bool + DeleteLastConfirmed bool CreateIfNone bool Quiet bool Host string @@ -74,6 +78,21 @@ func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error { return cmdutil.FlagErrorf("`--create-if-none` can only be used with `--edit-last`") } + if opts.DeleteLastConfirmed && !opts.DeleteLast { + return cmdutil.FlagErrorf("`--yes` should only be used with `--delete-last`") + } + + if opts.DeleteLast { + if inputFlags > 0 { + return cmdutil.FlagErrorf("should not provide comment body when using `--delete-last`") + } + if opts.IO.CanPrompt() || opts.DeleteLastConfirmed { + opts.Interactive = opts.IO.CanPrompt() + return nil + } + return cmdutil.FlagErrorf("should provide `--yes` to confirm deletion in non-interactive mode") + } + if inputFlags == 0 { if !opts.IO.CanPrompt() { return cmdutil.FlagErrorf("flags required when not running interactively") @@ -92,6 +111,9 @@ func CommentableRun(opts *CommentableOptions) error { return err } opts.Host = repo.RepoHost() + if opts.DeleteLast { + return deleteComment(commentable, opts) + } // Create new comment, bail before complexities of updating the last comment if !opts.EditLast { @@ -236,6 +258,53 @@ func updateComment(commentable Commentable, opts *CommentableOptions) error { return nil } +func deleteComment(commentable Commentable, opts *CommentableOptions) error { + comments := commentable.CurrentUserComments() + if len(comments) == 0 { + return errNoUserComments + } + + lastComment := comments[len(comments)-1] + + cs := opts.IO.ColorScheme() + + if opts.Interactive && !opts.DeleteLastConfirmed { + // This is not an ideal way of truncating a random string that may + // contain emojis or other kind of wide chars. + truncated := lastComment.Body + if len(lastComment.Body) > 40 { + truncated = lastComment.Body[:40] + "..." + } + + fmt.Fprintf(opts.IO.Out, "%s Deleted comments cannot be recovered.\n", cs.WarningIcon()) + ok, err := opts.ConfirmDeleteLastComment(truncated) + if err != nil { + return err + } + if !ok { + return errDeleteNotConfirmed + } + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + apiClient := api.NewClientFromHTTP(httpClient) + params := api.CommentDeleteInput{CommentId: lastComment.Identifier()} + deletionErr := api.CommentDelete(apiClient, opts.Host, params) + if deletionErr != nil { + return deletionErr + } + + if !opts.Quiet { + fmt.Fprintln(opts.IO.ErrOut, "Comment deleted") + } + + return nil +} + func CommentableConfirmSubmitSurvey(p Prompt) func() (bool, error) { return func() (bool, error) { return p.Confirm("Submit?", true) @@ -271,6 +340,12 @@ func CommentableEditSurvey(cf func() (gh.Config, error), io *iostreams.IOStreams } } +func CommentableConfirmDeleteLastComment(p Prompt) func(string) (bool, error) { + return func(body string) (bool, error) { + return p.Confirm(fmt.Sprintf("Delete the comment: %q?", body), true) + } +} + func waitForEnter(r io.Reader) error { scanner := bufio.NewScanner(r) scanner.Scan() From 3bcf9758ad24a3c8caf000c91b42a332c132877c Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 17 Apr 2025 22:13:31 +0200 Subject: [PATCH 074/249] Feature detect v1 projects on pr view --- pkg/cmd/pr/shared/finder.go | 29 +++++++++++-- pkg/cmd/pr/view/view.go | 5 +++ pkg/cmd/pr/view/view_test.go | 81 ++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 6d36ef816..b509f946c 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -16,6 +16,7 @@ import ( 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/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" o "github.com/cli/cli/v2/pkg/option" @@ -79,6 +80,10 @@ func RunCommandFinder(selector string, pr *api.PullRequest, repo ghrepo.Interfac return finder } +func ResetRunCommandFinder() { + runCommandFinder = nil +} + type FindOptions struct { // Selector can be a number with optional `#` prefix, a branch name with optional `:` prefix, or // a PR URL. @@ -89,6 +94,8 @@ type FindOptions struct { BaseBranch string // States lists the possible PR states to scope the PR-for-branch lookup to. States []string + + Detector fd.Detector } func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, error) { @@ -193,9 +200,11 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err fields.AddValues([]string{"id", "number"}) // for additional preload queries below if fields.Contains("isInMergeQueue") || fields.Contains("isMergeQueueEnabled") { - cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - detector := fd.NewDetector(cachedClient, f.baseRefRepo.RepoHost()) - prFeatures, err := detector.PullRequestFeatures() + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, f.baseRefRepo.RepoHost()) + } + prFeatures, err := opts.Detector.PullRequestFeatures() if err != nil { return nil, nil, err } @@ -211,6 +220,20 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err fields.Remove("projectItems") } + // TODO projectsV1Deprecation + // Remove this block + // When removing this, remember to remove `projectCards` from the list of default fields in pr/view.go + if fields.Contains("projectCards") { + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, f.baseRefRepo.RepoHost()) + } + + if opts.Detector.ProjectsV1() == gh.ProjectsV1Unsupported { + fields.Remove("projectCards") + } + } + var pr *api.PullRequest if f.prNumber > 0 { if numberFieldOnly { diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index 997f74d87..8a39d1134 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -10,6 +10,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -22,6 +23,9 @@ import ( type ViewOptions struct { IO *iostreams.IOStreams Browser browser.Browser + // TODO projectsV1Deprecation + // Remove this detector since it is only used for test validation. + Detector fd.Detector Finder shared.PRFinder Exporter cmdutil.Exporter @@ -89,6 +93,7 @@ func viewRun(opts *ViewOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, Fields: defaultFields, + Detector: opts.Detector, } if opts.BrowserMode { findOptions.Fields = []string{"url"} diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go index e7f572c76..3a2a87e5c 100644 --- a/pkg/cmd/pr/view/view_test.go +++ b/pkg/cmd/pr/view/view_test.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -175,6 +176,9 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t factory := &cmdutil.Factory{ IOStreams: ios, Browser: browser, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: rt}, nil + }, } cmd := NewCmdView(factory, nil) @@ -398,6 +402,8 @@ func TestPRView_Preview_nontty(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { + t.Cleanup(shared.ResetRunCommandFinder) + http := &httpmock.Registry{} defer http.Verify(t) @@ -602,6 +608,8 @@ func TestPRView_Preview(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { + t.Cleanup(shared.ResetRunCommandFinder) + http := &httpmock.Registry{} defer http.Verify(t) @@ -846,6 +854,8 @@ func TestPRView_nontty_Comments(t *testing.T) { } for name, tt := range tests { t.Run(name, func(t *testing.T) { + t.Cleanup(shared.ResetRunCommandFinder) + http := &httpmock.Registry{} defer http.Verify(t) @@ -869,3 +879,74 @@ func TestPRView_nontty_Comments(t *testing.T) { }) } } + +// TODO projectsV1Deprecation +// Remove this test. +func TestProjectsV1Deprecation(t *testing.T) { + t.Run("when projects v1 is supported, is included in query", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`projectCards`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + f := &cmdutil.Factory{ + IOStreams: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + } + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = viewRun(&ViewOptions{ + IO: ios, + Finder: shared.NewFinder(f), + Detector: &fd.EnabledDetectorMock{}, + + SelectorArg: "https://github.com/cli/cli/pull/123", + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) + + t.Run("when projects v1 is not supported, is not included in query", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Exclude( + t, + httpmock.GraphQL(`projectCards`), + ) + + f := &cmdutil.Factory{ + IOStreams: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + } + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = viewRun(&ViewOptions{ + IO: ios, + Finder: shared.NewFinder(f), + Detector: &fd.DisabledDetectorMock{}, + + SelectorArg: "https://github.com/cli/cli/pull/123", + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) +} From 284880c21edec4a913ce9d33705531fafeb14ee8 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 1 May 2025 20:22:43 +0100 Subject: [PATCH 075/249] Fix `StatusJSONResponse` usage (#10810) * Fix `StatusJSONResponse` usage Signed-off-by: Babak K. Shandiz * Replace `assert` with `require` Signed-off-by: Babak K. Shandiz * Improve assertion against errors Signed-off-by: Babak K. Shandiz * Add `JSONErrorResponse` helper func Signed-off-by: Babak K. Shandiz * Use `httpmock.JSONErrorResponse` Signed-off-by: Babak K. Shandiz * Replace `StatusJSONResponse` to `JSONErrorResponse` for better readibility Signed-off-by: Babak K. Shandiz * Fix improper use of `StatsJSONResponse` Signed-off-by: Babak K. Shandiz --------- Signed-off-by: Babak K. Shandiz --- pkg/cmd/gist/delete/delete_test.go | 50 ++++++++++++----------- pkg/cmd/gpg-key/delete/delete_test.go | 2 +- pkg/cmd/repo/autolink/delete/http_test.go | 22 +++++----- pkg/cmd/run/watch/watch_test.go | 2 +- pkg/httpmock/stub.go | 11 +++++ 5 files changed, 52 insertions(+), 35 deletions(-) diff --git a/pkg/cmd/gist/delete/delete_test.go b/pkg/cmd/gist/delete/delete_test.go index 24ca2bb33..2c4df8d8d 100644 --- a/pkg/cmd/gist/delete/delete_test.go +++ b/pkg/cmd/gist/delete/delete_test.go @@ -18,6 +18,7 @@ import ( ghAPI "github.com/cli/go-gh/v2/pkg/api" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewCmdDelete(t *testing.T) { @@ -327,11 +328,12 @@ func Test_deleteRun(t *testing.T) { func Test_gistDelete(t *testing.T) { tests := []struct { - name string - httpStubs func(*httpmock.Registry) - hostname string - gistID string - wantErr error + name string + httpStubs func(*httpmock.Registry) + hostname string + gistID string + wantErr error + wantErrString string }{ { name: "successful delete", @@ -343,36 +345,34 @@ func Test_gistDelete(t *testing.T) { }, hostname: "github.com", gistID: "1234", - wantErr: nil, }, { - name: "when an gist is not found, it returns a NotFoundError", + name: "when a gist is not found, it returns a NotFoundError", httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("DELETE", "gists/1234"), httpmock.StatusStringResponse(404, "{}"), ) }, - hostname: "github.com", - gistID: "1234", - wantErr: shared.NotFoundErr, + hostname: "github.com", + gistID: "1234", + wantErr: shared.NotFoundErr, // To make sure we return the pre-defined error instance. + wantErrString: "not found", }, { name: "when there is a non-404 error deleting the gist, that error is returned", httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("DELETE", "gists/1234"), - httpmock.StatusJSONResponse(500, `{"message": "arbitrary error"}`), + httpmock.JSONErrorResponse(500, ghAPI.HTTPError{ + StatusCode: 500, + Message: "arbitrary error", + }), ) }, - hostname: "github.com", - gistID: "1234", - wantErr: api.HTTPError{ - HTTPError: &ghAPI.HTTPError{ - StatusCode: 500, - Message: "arbitrary error", - }, - }, + hostname: "github.com", + gistID: "1234", + wantErrString: "HTTP 500: arbitrary error (https://api.github.com/gists/1234)", }, } @@ -383,12 +383,16 @@ func Test_gistDelete(t *testing.T) { client := api.NewClientFromHTTP(&http.Client{Transport: reg}) err := deleteGist(client, tt.hostname, tt.gistID) - if tt.wantErr != nil { - assert.ErrorAs(t, err, &tt.wantErr) + if tt.wantErrString == "" && tt.wantErr == nil { + require.NoError(t, err) } else { - assert.NoError(t, err) + if tt.wantErrString != "" { + require.EqualError(t, err, tt.wantErrString) + } + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } } - }) } } diff --git a/pkg/cmd/gpg-key/delete/delete_test.go b/pkg/cmd/gpg-key/delete/delete_test.go index 115e72db2..dc730b100 100644 --- a/pkg/cmd/gpg-key/delete/delete_test.go +++ b/pkg/cmd/gpg-key/delete/delete_test.go @@ -177,7 +177,7 @@ func Test_deleteRun(t *testing.T) { opts: DeleteOptions{KeyID: "ABC123", Confirmed: true}, httpStubs: func(reg *httpmock.Registry) { reg.Register(httpmock.REST("GET", "user/gpg_keys"), httpmock.StatusStringResponse(200, keysResp)) - reg.Register(httpmock.REST("DELETE", "user/gpg_keys/123"), httpmock.StatusJSONResponse(404, api.HTTPError{ + reg.Register(httpmock.REST("DELETE", "user/gpg_keys/123"), httpmock.JSONErrorResponse(404, api.HTTPError{ StatusCode: 404, Message: "GPG key 123 not found", })) diff --git a/pkg/cmd/repo/autolink/delete/http_test.go b/pkg/cmd/repo/autolink/delete/http_test.go index a2676178d..a0aec5e13 100644 --- a/pkg/cmd/repo/autolink/delete/http_test.go +++ b/pkg/cmd/repo/autolink/delete/http_test.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/go-gh/v2/pkg/api" "github.com/stretchr/testify/require" ) @@ -14,10 +15,10 @@ func TestAutolinkDeleter_Delete(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") tests := []struct { - name string - id string - stubStatus int - stubRespJSON string + name string + id string + stubStatus int + stubResp any expectErr bool expectedErrMsg string @@ -31,17 +32,18 @@ func TestAutolinkDeleter_Delete(t *testing.T) { name: "404 repo or autolink not found", id: "123", stubStatus: http.StatusNotFound, - stubRespJSON: `{}`, // API response not used in output expectErr: true, expectedErrMsg: "error deleting autolink: HTTP 404: Perhaps you are missing admin rights to the repository? (https://api.github.com/repos/OWNER/REPO/autolinks/123)", }, { - name: "500 unexpected error", - id: "123", - stubRespJSON: `{"messsage": "arbitrary error"}`, + name: "500 unexpected error", + id: "123", + stubResp: api.HTTPError{ + Message: "arbitrary error", + }, stubStatus: http.StatusInternalServerError, expectErr: true, - expectedErrMsg: "HTTP 500 (https://api.github.com/repos/OWNER/REPO/autolinks/123)", + expectedErrMsg: "HTTP 500: arbitrary error (https://api.github.com/repos/OWNER/REPO/autolinks/123)", }, } @@ -53,7 +55,7 @@ func TestAutolinkDeleter_Delete(t *testing.T) { http.MethodDelete, fmt.Sprintf("repos/%s/%s/autolinks/%s", repo.RepoOwner(), repo.RepoName(), tt.id), ), - httpmock.StatusJSONResponse(tt.stubStatus, tt.stubRespJSON), + httpmock.StatusJSONResponse(tt.stubStatus, tt.stubResp), ) defer reg.Verify(t) diff --git a/pkg/cmd/run/watch/watch_test.go b/pkg/cmd/run/watch/watch_test.go index d42e8d3d8..49e56217b 100644 --- a/pkg/cmd/run/watch/watch_test.go +++ b/pkg/cmd/run/watch/watch_test.go @@ -316,7 +316,7 @@ func TestWatchRun(t *testing.T) { ) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), - httpmock.StatusJSONResponse(404, api.HTTPError{ + httpmock.JSONErrorResponse(404, api.HTTPError{ StatusCode: 404, Message: "run 1234 not found", }), diff --git a/pkg/httpmock/stub.go b/pkg/httpmock/stub.go index 745c12417..3b03ae718 100644 --- a/pkg/httpmock/stub.go +++ b/pkg/httpmock/stub.go @@ -9,6 +9,8 @@ import ( "os" "regexp" "strings" + + "github.com/cli/go-gh/v2/pkg/api" ) type Matcher func(req *http.Request) bool @@ -161,6 +163,9 @@ func JSONResponse(body interface{}) Responder { } } +// StatusJSONResponse turns the given argument into a JSON response. +// +// The argument is not meant to be a JSON string, unless it's intentional. func StatusJSONResponse(status int, body interface{}) Responder { return func(req *http.Request) (*http.Response, error) { b, _ := json.Marshal(body) @@ -171,6 +176,12 @@ func StatusJSONResponse(status int, body interface{}) Responder { } } +// JSONErrorResponse is a type-safe helper to avoid confusion around the +// provided argument. +func JSONErrorResponse(status int, err api.HTTPError) Responder { + return StatusJSONResponse(status, err) +} + func FileResponse(filename string) Responder { return func(req *http.Request) (*http.Response, error) { f, err := os.Open(filename) From 64370ce73e6774cd5c7ec912c4cafbd513f2af73 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 2 May 2025 14:41:24 +0200 Subject: [PATCH 076/249] Cleanup run command stubbed finders in tests --- pkg/cmd/pr/checkout/checkout_test.go | 26 +++++------ pkg/cmd/pr/close/close_test.go | 14 +++--- pkg/cmd/pr/merge/merge_test.go | 66 ++++++++++++++-------------- pkg/cmd/pr/ready/ready_test.go | 10 ++--- pkg/cmd/pr/reopen/reopen_test.go | 8 ++-- pkg/cmd/pr/review/review_test.go | 8 ++-- pkg/cmd/pr/shared/finder.go | 30 ++++++++----- pkg/cmd/pr/view/view_test.go | 22 ++++------ 8 files changed, 93 insertions(+), 91 deletions(-) diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go index 40917fd76..496139423 100644 --- a/pkg/cmd/pr/checkout/checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -518,7 +518,7 @@ func TestPRCheckout_sameRepo(t *testing.T) { defer http.Verify(t) baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") - finder := shared.RunCommandFinder("123", pr, baseRepo) + finder := shared.StubFinderForRunCommandStyleTests(t, "123", pr, baseRepo) finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository", "maintainerCanModify"}) cs, cmdTeardown := run.Stub() @@ -539,7 +539,7 @@ func TestPRCheckout_existingBranch(t *testing.T) { defer http.Verify(t) baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") - shared.RunCommandFinder("123", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -570,7 +570,7 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) { defer http.Verify(t) baseRepo, pr := stubPR("OWNER/REPO", "hubot/REPO:feature") - finder := shared.RunCommandFinder("123", pr, baseRepo) + finder := shared.StubFinderForRunCommandStyleTests(t, "123", pr, baseRepo) finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository", "maintainerCanModify"}) cs, cmdTeardown := run.Stub() @@ -590,7 +590,7 @@ func TestPRCheckout_differentRepo(t *testing.T) { defer http.Verify(t) baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") - finder := shared.RunCommandFinder("123", pr, baseRepo) + finder := shared.StubFinderForRunCommandStyleTests(t, "123", pr, baseRepo) finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository", "maintainerCanModify"}) cs, cmdTeardown := run.Stub() @@ -613,7 +613,7 @@ func TestPRCheckout_differentRepoForce(t *testing.T) { defer http.Verify(t) baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") - finder := shared.RunCommandFinder("123", pr, baseRepo) + finder := shared.StubFinderForRunCommandStyleTests(t, "123", pr, baseRepo) finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository", "maintainerCanModify"}) cs, cmdTeardown := run.Stub() @@ -636,7 +636,7 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) { defer http.Verify(t) baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") - shared.RunCommandFinder("123", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -655,7 +655,7 @@ func TestPRCheckout_detachedHead(t *testing.T) { defer http.Verify(t) baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") - shared.RunCommandFinder("123", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -674,7 +674,7 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) { defer http.Verify(t) baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") - shared.RunCommandFinder("123", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -693,7 +693,7 @@ func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) { defer http.Verify(t) baseRepo, pr := stubPR("OWNER/REPO", "hubot/REPO:-foo") - shared.RunCommandFinder("123", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "123", pr, baseRepo) _, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -711,7 +711,7 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) { baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") pr.MaintainerCanModify = true - shared.RunCommandFinder("123", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -732,7 +732,7 @@ func TestPRCheckout_recurseSubmodules(t *testing.T) { http := &httpmock.Registry{} baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") - shared.RunCommandFinder("123", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -753,7 +753,7 @@ func TestPRCheckout_force(t *testing.T) { http := &httpmock.Registry{} baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") - shared.RunCommandFinder("123", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -774,7 +774,7 @@ func TestPRCheckout_detach(t *testing.T) { defer http.Verify(t) baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature") - shared.RunCommandFinder("123", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "123", pr, baseRepo) cs, cmdTeardown := run.Stub() defer cmdTeardown(t) diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go index 959af0e04..57ee0f0e6 100644 --- a/pkg/cmd/pr/close/close_test.go +++ b/pkg/cmd/pr/close/close_test.go @@ -110,7 +110,7 @@ func TestPrClose(t *testing.T) { baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") pr.Title = "The title of the PR" - shared.RunCommandFinder("96", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "96", pr, baseRepo) http.Register( httpmock.GraphQL(`mutation PullRequestClose\b`), @@ -133,7 +133,7 @@ func TestPrClose_alreadyClosed(t *testing.T) { baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") pr.State = "CLOSED" pr.Title = "The title of the PR" - shared.RunCommandFinder("96", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "96", pr, baseRepo) output, err := runCommand(http, true, "96") assert.NoError(t, err) @@ -147,7 +147,7 @@ func TestPrClose_deleteBranch_sameRepo(t *testing.T) { baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:blueberries") pr.Title = "The title of the PR" - shared.RunCommandFinder("96", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "96", pr, baseRepo) http.Register( httpmock.GraphQL(`mutation PullRequestClose\b`), @@ -181,7 +181,7 @@ func TestPrClose_deleteBranch_crossRepo(t *testing.T) { baseRepo, pr := stubPR("OWNER/REPO", "hubot/REPO:blueberries") pr.Title = "The title of the PR" - shared.RunCommandFinder("96", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "96", pr, baseRepo) http.Register( httpmock.GraphQL(`mutation PullRequestClose\b`), @@ -213,7 +213,7 @@ func TestPrClose_deleteBranch_sameBranch(t *testing.T) { baseRepo, pr := stubPR("OWNER/REPO:main", "OWNER/REPO:trunk") pr.Title = "The title of the PR" - shared.RunCommandFinder("96", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "96", pr, baseRepo) http.Register( httpmock.GraphQL(`mutation PullRequestClose\b`), @@ -248,7 +248,7 @@ func TestPrClose_deleteBranch_notInGitRepo(t *testing.T) { baseRepo, pr := stubPR("OWNER/REPO:main", "OWNER/REPO:trunk") pr.Title = "The title of the PR" - shared.RunCommandFinder("96", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "96", pr, baseRepo) http.Register( httpmock.GraphQL(`mutation PullRequestClose\b`), @@ -282,7 +282,7 @@ func TestPrClose_withComment(t *testing.T) { baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature") pr.Title = "The title of the PR" - shared.RunCommandFinder("96", pr, baseRepo) + shared.StubFinderForRunCommandStyleTests(t, "96", pr, baseRepo) http.Register( httpmock.GraphQL(`mutation CommentCreate\b`), diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index f1c2e37fe..4ca8c5d06 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -307,7 +307,7 @@ func TestPrMerge(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -348,7 +348,7 @@ func TestPrMerge_blocked(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -379,7 +379,7 @@ func TestPrMerge_dirty(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -413,7 +413,7 @@ func TestPrMerge_nontty(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -451,7 +451,7 @@ func TestPrMerge_editMessage_nontty(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -490,7 +490,7 @@ func TestPrMerge_withRepoFlag(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -529,7 +529,7 @@ func TestPrMerge_withMatchCommitHeadFlag(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -570,7 +570,7 @@ func TestPrMerge_withAuthorFlag(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -612,7 +612,7 @@ func TestPrMerge_deleteBranch(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{ ID: "PR_10", @@ -663,7 +663,7 @@ func TestPrMerge_deleteBranch_mergeQueue(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{ ID: "PR_10", @@ -686,7 +686,7 @@ func TestPrMerge_deleteBranch_nonDefault(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{ ID: "PR_10", @@ -737,7 +737,7 @@ func TestPrMerge_deleteBranch_onlyLocally(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{ ID: "PR_10", @@ -785,7 +785,7 @@ func TestPrMerge_deleteBranch_checkoutNewBranch(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{ ID: "PR_10", @@ -836,7 +836,7 @@ func TestPrMerge_deleteNonCurrentBranch(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "blueberries", &api.PullRequest{ ID: "PR_10", @@ -893,7 +893,7 @@ func Test_nonDivergingPullRequest(t *testing.T) { } stubCommit(pr, "COMMITSHA1") - shared.RunCommandFinder("", pr, baseRepo("OWNER", "REPO", "main")) + shared.StubFinderForRunCommandStyleTests(t, "", pr, baseRepo("OWNER", "REPO", "main")) http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), @@ -933,7 +933,7 @@ func Test_divergingPullRequestWarning(t *testing.T) { } stubCommit(pr, "COMMITSHA1") - shared.RunCommandFinder("", pr, baseRepo("OWNER", "REPO", "main")) + shared.StubFinderForRunCommandStyleTests(t, "", pr, baseRepo("OWNER", "REPO", "main")) http.Register( httpmock.GraphQL(`mutation PullRequestMerge\b`), @@ -964,7 +964,7 @@ func Test_pullRequestWithoutCommits(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{ ID: "PR_10", @@ -1003,7 +1003,7 @@ func TestPrMerge_rebase(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "2", &api.PullRequest{ ID: "THE-ID", @@ -1044,7 +1044,7 @@ func TestPrMerge_squash(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "3", &api.PullRequest{ ID: "THE-ID", @@ -1084,7 +1084,7 @@ func TestPrMerge_alreadyMerged(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "4", &api.PullRequest{ ID: "THE-ID", @@ -1129,7 +1129,7 @@ func TestPrMerge_alreadyMerged_withMergeStrategy(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "4", &api.PullRequest{ ID: "THE-ID", @@ -1159,7 +1159,7 @@ func TestPrMerge_alreadyMerged_withMergeStrategy_TTY(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "4", &api.PullRequest{ ID: "THE-ID", @@ -1200,7 +1200,7 @@ func TestPrMerge_alreadyMerged_withMergeStrategy_crossRepo(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "4", &api.PullRequest{ ID: "THE-ID", @@ -1239,7 +1239,7 @@ func TestPRMergeTTY(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{ ID: "THE-ID", @@ -1305,7 +1305,7 @@ func TestPRMergeTTY_withDeleteBranch(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{ ID: "THE-ID", @@ -1468,7 +1468,7 @@ func TestPRMergeEmptyStrategyNonTTY(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -1495,7 +1495,7 @@ func TestPRTTY_cancelled(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{ID: "THE-ID", Number: 123, Title: "title", MergeStateStatus: "CLEAN"}, ghrepo.New("OWNER", "REPO"), @@ -1679,7 +1679,7 @@ func TestPrInMergeQueue(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -1710,7 +1710,7 @@ func TestPrAddToMergeQueueWithMergeMethod(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -1748,7 +1748,7 @@ func TestPrAddToMergeQueueClean(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -1788,7 +1788,7 @@ func TestPrAddToMergeQueueBlocked(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -1828,7 +1828,7 @@ func TestPrAddToMergeQueueAdmin(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", @@ -1897,7 +1897,7 @@ func TestPrAddToMergeQueueAdminWithMergeStrategy(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) - shared.RunCommandFinder( + shared.StubFinderForRunCommandStyleTests(t, "1", &api.PullRequest{ ID: "THE-ID", diff --git a/pkg/cmd/pr/ready/ready_test.go b/pkg/cmd/pr/ready/ready_test.go index 9046ab3ac..5a6053a17 100644 --- a/pkg/cmd/pr/ready/ready_test.go +++ b/pkg/cmd/pr/ready/ready_test.go @@ -124,7 +124,7 @@ func TestPRReady(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("123", &api.PullRequest{ + shared.StubFinderForRunCommandStyleTests(t, "123", &api.PullRequest{ ID: "THE-ID", Number: 123, State: "OPEN", @@ -149,7 +149,7 @@ func TestPRReady_alreadyReady(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("123", &api.PullRequest{ + shared.StubFinderForRunCommandStyleTests(t, "123", &api.PullRequest{ ID: "THE-ID", Number: 123, State: "OPEN", @@ -166,7 +166,7 @@ func TestPRReadyUndo(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("123", &api.PullRequest{ + shared.StubFinderForRunCommandStyleTests(t, "123", &api.PullRequest{ ID: "THE-ID", Number: 123, State: "OPEN", @@ -191,7 +191,7 @@ func TestPRReadyUndo_alreadyDraft(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("123", &api.PullRequest{ + shared.StubFinderForRunCommandStyleTests(t, "123", &api.PullRequest{ ID: "THE-ID", Number: 123, State: "OPEN", @@ -208,7 +208,7 @@ func TestPRReady_closed(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("123", &api.PullRequest{ + shared.StubFinderForRunCommandStyleTests(t, "123", &api.PullRequest{ ID: "THE-ID", Number: 123, State: "CLOSED", diff --git a/pkg/cmd/pr/reopen/reopen_test.go b/pkg/cmd/pr/reopen/reopen_test.go index 856e19172..9fb3702c0 100644 --- a/pkg/cmd/pr/reopen/reopen_test.go +++ b/pkg/cmd/pr/reopen/reopen_test.go @@ -53,7 +53,7 @@ func TestPRReopen(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("123", &api.PullRequest{ + shared.StubFinderForRunCommandStyleTests(t, "123", &api.PullRequest{ ID: "THE-ID", Number: 123, State: "CLOSED", @@ -78,7 +78,7 @@ func TestPRReopen_alreadyOpen(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("123", &api.PullRequest{ + shared.StubFinderForRunCommandStyleTests(t, "123", &api.PullRequest{ ID: "THE-ID", Number: 123, State: "OPEN", @@ -95,7 +95,7 @@ func TestPRReopen_alreadyMerged(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("123", &api.PullRequest{ + shared.StubFinderForRunCommandStyleTests(t, "123", &api.PullRequest{ ID: "THE-ID", Number: 123, State: "MERGED", @@ -112,7 +112,7 @@ func TestPRReopen_withComment(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("123", &api.PullRequest{ + shared.StubFinderForRunCommandStyleTests(t, "123", &api.PullRequest{ ID: "THE-ID", Number: 123, State: "CLOSED", diff --git a/pkg/cmd/pr/review/review_test.go b/pkg/cmd/pr/review/review_test.go index f9e00c3b8..684617ca9 100644 --- a/pkg/cmd/pr/review/review_test.go +++ b/pkg/cmd/pr/review/review_test.go @@ -235,7 +235,7 @@ func TestPRReview(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("", &api.PullRequest{ID: "THE-ID"}, ghrepo.New("OWNER", "REPO")) + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{ID: "THE-ID"}, ghrepo.New("OWNER", "REPO")) http.Register( httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), @@ -261,7 +261,7 @@ func TestPRReview_interactive(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("", &api.PullRequest{ID: "THE-ID", Number: 123}, ghrepo.New("OWNER", "REPO")) + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{ID: "THE-ID", Number: 123}, ghrepo.New("OWNER", "REPO")) http.Register( httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), @@ -293,7 +293,7 @@ func TestPRReview_interactive_no_body(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("", &api.PullRequest{ID: "THE-ID", Number: 123}, ghrepo.New("OWNER", "REPO")) + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{ID: "THE-ID", Number: 123}, ghrepo.New("OWNER", "REPO")) pm := &prompter.PrompterMock{ SelectFunc: func(_, _ string, _ []string) (int, error) { return 2, nil }, @@ -308,7 +308,7 @@ func TestPRReview_interactive_blank_approve(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("", &api.PullRequest{ID: "THE-ID", Number: 123}, ghrepo.New("OWNER", "REPO")) + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{ID: "THE-ID", Number: 123}, ghrepo.New("OWNER", "REPO")) http.Register( httpmock.GraphQL(`mutation PullRequestReviewAdd\b`), diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index b509f946c..04e8baf2c 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -10,6 +10,7 @@ import ( "sort" "strconv" "strings" + "testing" "time" "github.com/cli/cli/v2/api" @@ -55,9 +56,9 @@ type finder struct { } func NewFinder(factory *cmdutil.Factory) PRFinder { - if runCommandFinder != nil { - f := runCommandFinder - runCommandFinder = &mockFinder{err: errors.New("you must use a RunCommandFinder to stub PR lookups")} + if finderForRunCommandStyleTests != nil { + f := finderForRunCommandStyleTests + finderForRunCommandStyleTests = &mockFinder{err: errors.New("you must use StubFinderForRunCommandStyleTests to stub PR lookups")} return f } @@ -71,17 +72,24 @@ func NewFinder(factory *cmdutil.Factory) PRFinder { } } -var runCommandFinder PRFinder +var finderForRunCommandStyleTests PRFinder -// RunCommandFinder is the NewMockFinder substitute to be used ONLY in runCommand-style tests. -func RunCommandFinder(selector string, pr *api.PullRequest, repo ghrepo.Interface) *mockFinder { +// StubFinderForRunCommandStyleTests is the NewMockFinder substitute to be used ONLY in runCommand-style tests. +func StubFinderForRunCommandStyleTests(t *testing.T, selector string, pr *api.PullRequest, repo ghrepo.Interface) *mockFinder { + // Create a new mock finder and override the "runCommandFinder" variable so that calls to + // NewFinder() will return this mock. This is a bad pattern, and a result of old style runCommand + // tests that would ideally be replaced. The reason we need to do this is that the runCommand style tests + // construct the cobra command via NewCmd* functions, and then Execute them directly, providing no opportunity + // to inject a test double unless it's on the factory, which finder never is, because only PR commands need it. finder := NewMockFinder(selector, pr, repo) - runCommandFinder = finder - return finder -} + finderForRunCommandStyleTests = finder -func ResetRunCommandFinder() { - runCommandFinder = nil + // Ensure that at the end of the test, we reset the "runCommandFinder" variable so that tests are isolated, + // at least if they are run sequentially. + t.Cleanup(func() { + finderForRunCommandStyleTests = nil + }) + return finder } type FindOptions struct { diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go index 3a2a87e5c..ec1691305 100644 --- a/pkg/cmd/pr/view/view_test.go +++ b/pkg/cmd/pr/view/view_test.go @@ -402,14 +402,12 @@ func TestPRView_Preview_nontty(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - t.Cleanup(shared.ResetRunCommandFinder) - http := &httpmock.Registry{} defer http.Verify(t) pr, err := prFromFixtures(tc.fixtures) require.NoError(t, err) - shared.RunCommandFinder("12", pr, ghrepo.New("OWNER", "REPO")) + shared.StubFinderForRunCommandStyleTests(t, "12", pr, ghrepo.New("OWNER", "REPO")) output, err := runCommand(http, tc.branch, false, tc.args) if err != nil { @@ -608,14 +606,12 @@ func TestPRView_Preview(t *testing.T) { for name, tc := range tests { t.Run(name, func(t *testing.T) { - t.Cleanup(shared.ResetRunCommandFinder) - http := &httpmock.Registry{} defer http.Verify(t) pr, err := prFromFixtures(tc.fixtures) require.NoError(t, err) - shared.RunCommandFinder("12", pr, ghrepo.New("OWNER", "REPO")) + shared.StubFinderForRunCommandStyleTests(t, "12", pr, ghrepo.New("OWNER", "REPO")) output, err := runCommand(http, tc.branch, true, tc.args) if err != nil { @@ -638,7 +634,7 @@ func TestPRView_web_currentBranch(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("", &api.PullRequest{URL: "https://github.com/OWNER/REPO/pull/10"}, ghrepo.New("OWNER", "REPO")) + shared.StubFinderForRunCommandStyleTests(t, "", &api.PullRequest{URL: "https://github.com/OWNER/REPO/pull/10"}, ghrepo.New("OWNER", "REPO")) _, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -657,7 +653,7 @@ func TestPRView_web_noResultsForBranch(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - shared.RunCommandFinder("", nil, nil) + shared.StubFinderForRunCommandStyleTests(t, "", nil, nil) _, cmdTeardown := run.Stub() defer cmdTeardown(t) @@ -749,9 +745,9 @@ func TestPRView_tty_Comments(t *testing.T) { if len(tt.fixtures) > 0 { pr, err := prFromFixtures(tt.fixtures) require.NoError(t, err) - shared.RunCommandFinder("123", pr, ghrepo.New("OWNER", "REPO")) + shared.StubFinderForRunCommandStyleTests(t, "123", pr, ghrepo.New("OWNER", "REPO")) } else { - shared.RunCommandFinder("123", nil, nil) + shared.StubFinderForRunCommandStyleTests(t, "123", nil, nil) } output, err := runCommand(http, tt.branch, true, tt.cli) @@ -854,17 +850,15 @@ func TestPRView_nontty_Comments(t *testing.T) { } for name, tt := range tests { t.Run(name, func(t *testing.T) { - t.Cleanup(shared.ResetRunCommandFinder) - http := &httpmock.Registry{} defer http.Verify(t) if len(tt.fixtures) > 0 { pr, err := prFromFixtures(tt.fixtures) require.NoError(t, err) - shared.RunCommandFinder("123", pr, ghrepo.New("OWNER", "REPO")) + shared.StubFinderForRunCommandStyleTests(t, "123", pr, ghrepo.New("OWNER", "REPO")) } else { - shared.RunCommandFinder("123", nil, nil) + shared.StubFinderForRunCommandStyleTests(t, "123", nil, nil) } output, err := runCommand(http, tt.branch, false, tt.cli) From e995a873cb7ad4be48c1ab1fbf51a85b7d0280c4 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 1 May 2025 15:58:48 +0200 Subject: [PATCH 077/249] Feature detect v1 projects on non-interactive pr create --- api/queries_repo.go | 41 ++++++----- api/queries_repo_test.go | 8 +- pkg/cmd/pr/create/create.go | 30 ++++++-- pkg/cmd/pr/create/create_test.go | 121 ++++++++++++++++++++++++++++++- pkg/cmd/pr/shared/editable.go | 6 +- pkg/cmd/pr/shared/params.go | 4 +- 6 files changed, 174 insertions(+), 36 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 27e21eb32..93a32d80c 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -738,34 +738,37 @@ func (m *RepoMetadataResult) LabelsToIDs(names []string) ([]string, error) { return ids, nil } -// ProjectsToIDs returns two arrays: +// ProjectsTitlesToIDs returns two arrays: // - the first contains IDs of projects V1 // - the second contains IDs of projects V2 // - if neither project V1 or project V2 can be found with a given name, then an error is returned -func (m *RepoMetadataResult) ProjectsToIDs(names []string) ([]string, []string, error) { +func (m *RepoMetadataResult) ProjectsTitlesToIDs(titles []string) ([]string, []string, error) { var ids []string var idsV2 []string - for _, projectName := range names { - id, found := m.projectNameToID(projectName) + for _, title := range titles { + id, found := m.v1ProjectNameToID(title) if found { ids = append(ids, id) continue } - idV2, found := m.projectV2TitleToID(projectName) + idV2, found := m.v2ProjectTitleToID(title) if found { idsV2 = append(idsV2, idV2) continue } - return nil, nil, fmt.Errorf("'%s' not found", projectName) + return nil, nil, fmt.Errorf("'%s' not found", title) } return ids, idsV2, nil } -func (m *RepoMetadataResult) projectNameToID(projectName string) (string, bool) { +// We use the word "titles" when referring to v1 and v2 projects. +// In reality, v1 projects really have "names", so there is a bit of a +// mismatch we just need to gloss over. +func (m *RepoMetadataResult) v1ProjectNameToID(name string) (string, bool) { for _, p := range m.Projects { - if strings.EqualFold(projectName, p.Name) { + if strings.EqualFold(name, p.Name) { return p.ID, true } } @@ -773,9 +776,9 @@ func (m *RepoMetadataResult) projectNameToID(projectName string) (string, bool) return "", false } -func (m *RepoMetadataResult) projectV2TitleToID(projectTitle string) (string, bool) { +func (m *RepoMetadataResult) v2ProjectTitleToID(title string) (string, bool) { for _, p := range m.ProjectsV2 { - if strings.EqualFold(projectTitle, p.Title) { + if strings.EqualFold(title, p.Title) { return p.ID, true } } @@ -783,8 +786,8 @@ func (m *RepoMetadataResult) projectV2TitleToID(projectTitle string) (string, bo return "", false } -func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string, projectsV1Support gh.ProjectsV1Support) ([]string, error) { - paths := make([]string, 0, len(projectNames)) +func ProjectTitlesToPaths(client *Client, repo ghrepo.Interface, titles []string, projectsV1Support gh.ProjectsV1Support) ([]string, error) { + paths := make([]string, 0, len(titles)) matchedPaths := map[string]struct{}{} // TODO: ProjectsV1Cleanup @@ -796,9 +799,9 @@ func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []s return nil, err } - for _, projectName := range projectNames { + for _, title := range titles { for _, p := range v1Projects { - if strings.EqualFold(projectName, p.Name) { + if strings.EqualFold(title, p.Name) { pathParts := strings.Split(p.ResourcePath, "/") var path string if pathParts[1] == "orgs" || pathParts[1] == "users" { @@ -807,7 +810,7 @@ func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []s path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4]) } paths = append(paths, path) - matchedPaths[projectName] = struct{}{} + matchedPaths[title] = struct{}{} break } } @@ -820,15 +823,15 @@ func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []s return nil, err } - for _, projectName := range projectNames { + for _, title := range titles { // If we already found a v1 project with this name, skip it - if _, ok := matchedPaths[projectName]; ok { + if _, ok := matchedPaths[title]; ok { continue } found := false for _, p := range v2Projects { - if strings.EqualFold(projectName, p.Title) { + if strings.EqualFold(title, p.Title) { pathParts := strings.Split(p.ResourcePath, "/") var path string if pathParts[1] == "orgs" || pathParts[1] == "users" { @@ -843,7 +846,7 @@ func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []s } if !found { - return nil, fmt.Errorf("'%s' not found", projectName) + return nil, fmt.Errorf("'%s' not found", title) } } diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 72ed35776..01fc7a4c7 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -187,7 +187,7 @@ func Test_RepoMetadata(t *testing.T) { expectedProjectIDs := []string{"TRIAGEID", "ROADMAPID"} expectedProjectV2IDs := []string{"TRIAGEV2ID", "ROADMAPV2ID", "MONALISAV2ID"} - projectIDs, projectV2IDs, err := result.ProjectsToIDs([]string{"triage", "roadmap", "triagev2", "roadmapv2", "monalisav2"}) + projectIDs, projectV2IDs, err := result.ProjectsTitlesToIDs([]string{"triage", "roadmap", "triagev2", "roadmapv2", "monalisav2"}) if err != nil { t.Errorf("error resolving projects: %v", err) } @@ -273,7 +273,7 @@ func Test_ProjectNamesToPaths(t *testing.T) { } } } } `)) - projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"}, gh.ProjectsV1Supported) + projectPaths, err := ProjectTitlesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2", "MonalisaV2"}, gh.ProjectsV1Supported) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -331,7 +331,7 @@ func Test_ProjectNamesToPaths(t *testing.T) { } } } } `)) - projectPaths, err := ProjectNamesToPaths(client, repo, []string{"TriageV2", "RoadmapV2", "MonalisaV2"}, gh.ProjectsV1Unsupported) + projectPaths, err := ProjectTitlesToPaths(client, repo, []string{"TriageV2", "RoadmapV2", "MonalisaV2"}, gh.ProjectsV1Unsupported) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -374,7 +374,7 @@ func Test_ProjectNamesToPaths(t *testing.T) { } } } } `)) - _, err := ProjectNamesToPaths(client, repo, []string{"TriageV2"}, gh.ProjectsV1Unsupported) + _, err := ProjectTitlesToPaths(client, repo, []string{"TriageV2"}, gh.ProjectsV1Unsupported) require.Equal(t, err, fmt.Errorf("'TriageV2' not found")) }) } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 7f960bce4..483b8246b 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -18,6 +18,7 @@ import ( ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" @@ -31,6 +32,7 @@ import ( type CreateOptions struct { // This struct stores user input and factory functions + Detector fd.Detector HttpClient func() (*http.Client, error) GitClient *git.Client Config func() (gh.Config, error) @@ -363,6 +365,20 @@ func createRun(opts *CreateOptions) error { return err } + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + // TODO projectsV1Deprecation + // Remove this section as we should no longer need to detect + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, ctx.PRRefs.BaseRepo().RepoHost()) + } + + projectsV1Support := opts.Detector.ProjectsV1() + client := ctx.Client state, err := NewIssueState(*ctx, *opts) @@ -384,7 +400,7 @@ func createRun(opts *CreateOptions) error { if err != nil { return err } - openURL, err = generateCompareURL(*ctx, *state) + openURL, err = generateCompareURL(*ctx, *state, gh.ProjectsV1Supported) if err != nil { return err } @@ -441,7 +457,7 @@ func createRun(opts *CreateOptions) error { return err } // TODO wm: revisit project support - return submitPR(*opts, *ctx, *state, gh.ProjectsV1Supported) + return submitPR(*opts, *ctx, *state, projectsV1Support) } if opts.RecoverFile != "" { @@ -518,7 +534,7 @@ func createRun(opts *CreateOptions) error { } } - openURL, err = generateCompareURL(*ctx, *state) + openURL, err = generateCompareURL(*ctx, *state, gh.ProjectsV1Supported) if err != nil { return err } @@ -568,12 +584,12 @@ func createRun(opts *CreateOptions) error { if action == shared.SubmitDraftAction { state.Draft = true // TODO wm: revisit project support - return submitPR(*opts, *ctx, *state, gh.ProjectsV1Supported) + return submitPR(*opts, *ctx, *state, projectsV1Support) } if action == shared.SubmitAction { // TODO wm: revisit project support - return submitPR(*opts, *ctx, *state, gh.ProjectsV1Supported) + return submitPR(*opts, *ctx, *state, projectsV1Support) } err = errors.New("expected to cancel, preview, or submit") @@ -1216,13 +1232,13 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { return pushBranch() } -func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (string, error) { +func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState, projectsV1Support gh.ProjectsV1Support) (string, error) { u := ghrepo.GenerateRepoURL( ctx.PRRefs.BaseRepo(), "compare/%s...%s?expand=1", url.PathEscape(ctx.PRRefs.BaseRef()), url.PathEscape(ctx.PRRefs.QualifiedHeadRef())) // TODO wm: revisit project support - url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PRRefs.BaseRepo(), u, state, gh.ProjectsV1Supported) + url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PRRefs.BaseRepo(), u, state, projectsV1Support) if err != nil { return "", err } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 2a88b5eee..f3b99bc89 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -15,6 +15,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" @@ -1618,6 +1619,7 @@ func Test_createRun(t *testing.T) { } opts := CreateOptions{} + opts.Detector = &fd.EnabledDetectorMock{} opts.Prompter = pm ios, _, stdout, stderr := iostreams.Test() @@ -1941,7 +1943,8 @@ func Test_generateCompareURL(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := generateCompareURL(tt.ctx, tt.state) + // TODO wm: projects v1 support? + got, err := generateCompareURL(tt.ctx, tt.state, gh.ProjectsV1Supported) if (err != nil) != tt.wantErr { t.Errorf("generateCompareURL() error = %v, wantErr %v", err, tt.wantErr) return @@ -2009,3 +2012,119 @@ func mockRetrieveProjects(_ *testing.T, reg *httpmock.Registry) { } // TODO interactive metadata tests once: 1) we have test utils for Prompter and 2) metadata questions use Prompter + +// TODO projectsV1Deprecation +// Remove this test. +func TestProjectsV1Deprecation(t *testing.T) { + + t.Run("non-interactive submission", func(t *testing.T) { + t.Run("when projects v1 is supported, queries for it", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "main") + reg.Register( + // ( is required to avoid matching projectsV2 + httpmock.GraphQL(`projects\(`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = createRun(&CreateOptions{ + Detector: &fd.EnabledDetectorMock{}, + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{ + GhPath: "some/path/gh", + GitPath: "some/path/git", + }, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{ + Name: "upstream", + Resolved: "base", + }, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Finder: shared.NewMockFinder("feature", nil, nil), + + HeadBranch: "feature", + + TitleProvided: true, + BodyProvided: true, + Title: "Test Title", + Body: "Test Body", + + // Required to force a lookup of projects + Projects: []string{"Project"}, + }) + + // Verify that our request contained projects + reg.Verify(t) + }) + + t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "main") + // ( is required to avoid matching projectsV2 + reg.Exclude(t, httpmock.GraphQL(`projects\(`)) + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") + + // Ignore the error because we're not really interested in it. + _ = createRun(&CreateOptions{ + Detector: &fd.DisabledDetectorMock{}, + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{ + GhPath: "some/path/gh", + GitPath: "some/path/git", + }, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{ + Name: "upstream", + Resolved: "base", + }, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Finder: shared.NewMockFinder("feature", nil, nil), + + HeadBranch: "feature", + + TitleProvided: true, + BodyProvided: true, + Title: "Test Title", + Body: "Test Body", + + // Required to force a lookup of projects + Projects: []string{"Project"}, + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) + }) +} diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 0bebb999a..e73b3c294 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -137,7 +137,7 @@ func (e Editable) ProjectIds() (*[]string, error) { s.RemoveValues(e.Projects.Remove) e.Projects.Value = s.ToSlice() } - p, _, err := e.Metadata.ProjectsToIDs(e.Projects.Value) + p, _, err := e.Metadata.ProjectsTitlesToIDs(e.Projects.Value) return &p, err } @@ -171,14 +171,14 @@ func (e Editable) ProjectV2Ids() (*[]string, *[]string, error) { var err error if addTitles.Len() > 0 { - _, addIds, err = e.Metadata.ProjectsToIDs(addTitles.ToSlice()) + _, addIds, err = e.Metadata.ProjectsTitlesToIDs(addTitles.ToSlice()) if err != nil { return nil, nil, err } } if removeTitles.Len() > 0 { - _, removeIds, err = e.Metadata.ProjectsToIDs(removeTitles.ToSlice()) + _, removeIds, err = e.Metadata.ProjectsTitlesToIDs(removeTitles.ToSlice()) if err != nil { return nil, nil, err } diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 4f36a80aa..08968939d 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -36,7 +36,7 @@ func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, ba q.Set("labels", strings.Join(state.Labels, ",")) } if len(state.ProjectTitles) > 0 { - projectPaths, err := api.ProjectNamesToPaths(client, baseRepo, state.ProjectTitles, projectsV1Support) + projectPaths, err := api.ProjectTitlesToPaths(client, baseRepo, state.ProjectTitles, projectsV1Support) if err != nil { return "", fmt.Errorf("could not add to project: %w", err) } @@ -119,7 +119,7 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par } params["labelIds"] = labelIDs - projectIDs, projectV2IDs, err := tb.MetadataResult.ProjectsToIDs(tb.ProjectTitles) + projectIDs, projectV2IDs, err := tb.MetadataResult.ProjectsTitlesToIDs(tb.ProjectTitles) if err != nil { return fmt.Errorf("could not add to project: %w", err) } From 9822bb5d07fd9f2829572102a01318ca4c3bc909 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 1 May 2025 17:31:42 +0200 Subject: [PATCH 078/249] Feature detect v1 projects on web mode pr create --- pkg/cmd/pr/create/create.go | 2 +- pkg/cmd/pr/create/create_test.go | 115 +++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 483b8246b..6ce11a712 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -400,7 +400,7 @@ func createRun(opts *CreateOptions) error { if err != nil { return err } - openURL, err = generateCompareURL(*ctx, *state, gh.ProjectsV1Supported) + openURL, err = generateCompareURL(*ctx, *state, projectsV1Support) if err != nil { return err } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index f3b99bc89..59a974df6 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -2127,4 +2127,119 @@ func TestProjectsV1Deprecation(t *testing.T) { reg.Verify(t) }) }) + + t.Run("web mode", func(t *testing.T) { + t.Run("when projects v1 is supported, queries for it", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "main") + reg.Register( + // ( is required to avoid matching projectsV2 + httpmock.GraphQL(`projects\(`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = createRun(&CreateOptions{ + Detector: &fd.EnabledDetectorMock{}, + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{ + GhPath: "some/path/gh", + GitPath: "some/path/git", + }, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{ + Name: "upstream", + Resolved: "base", + }, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Finder: shared.NewMockFinder("feature", nil, nil), + + WebMode: true, + + HeadBranch: "feature", + + TitleProvided: true, + BodyProvided: true, + Title: "Test Title", + Body: "Test Body", + + // Required to force a lookup of projects + Projects: []string{"Project"}, + }) + + // Verify that our request contained projects + reg.Verify(t) + }) + + t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.StubRepoInfoResponse("OWNER", "REPO", "main") + // ( is required to avoid matching projectsV2 + reg.Exclude(t, httpmock.GraphQL(`projects\(`)) + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") + + // Ignore the error because we're not really interested in it. + _ = createRun(&CreateOptions{ + Detector: &fd.DisabledDetectorMock{}, + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{ + GhPath: "some/path/gh", + GitPath: "some/path/git", + }, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{ + Name: "upstream", + Resolved: "base", + }, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Finder: shared.NewMockFinder("feature", nil, nil), + + WebMode: true, + + HeadBranch: "feature", + + TitleProvided: true, + BodyProvided: true, + Title: "Test Title", + Body: "Test Body", + + // Required to force a lookup of projects + Projects: []string{"Project"}, + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) + }) } From 5a3aee056a9f0661d7c088a4a634b03e30d2d8eb Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 2 May 2025 16:43:36 +0200 Subject: [PATCH 079/249] Feature detect v1 projects on interactive pr create --- internal/prompter/test.go | 12 ++ pkg/cmd/pr/create/create.go | 7 +- pkg/cmd/pr/create/create_test.go | 210 ++++++++++++++++++++++++++++++- 3 files changed, 219 insertions(+), 10 deletions(-) diff --git a/internal/prompter/test.go b/internal/prompter/test.go index 04375ce76..dfa124fca 100644 --- a/internal/prompter/test.go +++ b/internal/prompter/test.go @@ -141,6 +141,18 @@ func IndexFor(options []string, answer string) (int, error) { return -1, NoSuchAnswerErr(answer, options) } +func IndexesFor(options []string, answers ...string) ([]int, error) { + indexes := make([]int, len(answers)) + for i, answer := range answers { + index, err := IndexFor(options, answer) + if err != nil { + return nil, err + } + indexes[i] = index + } + return indexes, nil +} + func NoSuchPromptErr(prompt string) error { return fmt.Errorf("no such prompt '%s'", prompt) } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 6ce11a712..64469543f 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -456,7 +456,6 @@ func createRun(opts *CreateOptions) error { if err != nil { return err } - // TODO wm: revisit project support return submitPR(*opts, *ctx, *state, projectsV1Support) } @@ -553,8 +552,7 @@ func createRun(opts *CreateOptions) error { Repo: ctx.PRRefs.BaseRepo(), State: state, } - // TODO wm: revisit project support - err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, gh.ProjectsV1Supported) + err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, projectsV1Support) if err != nil { return err } @@ -583,12 +581,10 @@ func createRun(opts *CreateOptions) error { if action == shared.SubmitDraftAction { state.Draft = true - // TODO wm: revisit project support return submitPR(*opts, *ctx, *state, projectsV1Support) } if action == shared.SubmitAction { - // TODO wm: revisit project support return submitPR(*opts, *ctx, *state, projectsV1Support) } @@ -1237,7 +1233,6 @@ func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState, proj ctx.PRRefs.BaseRepo(), "compare/%s...%s?expand=1", url.PathEscape(ctx.PRRefs.BaseRef()), url.PathEscape(ctx.PRRefs.QualifiedHeadRef())) - // TODO wm: revisit project support url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PRRefs.BaseRepo(), u, state, projectsV1Support) if err != nil { return "", err diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 59a974df6..ac96db0d6 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1943,7 +1943,6 @@ func Test_generateCompareURL(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // TODO wm: projects v1 support? got, err := generateCompareURL(tt.ctx, tt.state, gh.ProjectsV1Supported) if (err != nil) != tt.wantErr { t.Errorf("generateCompareURL() error = %v, wantErr %v", err, tt.wantErr) @@ -2011,8 +2010,6 @@ func mockRetrieveProjects(_ *testing.T, reg *httpmock.Registry) { `)) } -// TODO interactive metadata tests once: 1) we have test utils for Prompter and 2) metadata questions use Prompter - // TODO projectsV1Deprecation // Remove this test. func TestProjectsV1Deprecation(t *testing.T) { @@ -2128,6 +2125,211 @@ func TestProjectsV1Deprecation(t *testing.T) { }) }) + t.Run("interactive submission", func(t *testing.T) { + t.Run("when projects v1 is supported, queries for it", func(t *testing.T) { + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") + cs.Register("git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "") + cs.Register(`git rev-parse --show-toplevel`, 0, "") + + // When the command is run + reg := &httpmock.Registry{} + reg.StubRepoResponse("OWNER", "REPO") + + reg.Register( + httpmock.GraphQL(`query PullRequestTemplates\b`), + httpmock.StringResponse(`{ "data": { "repository": { "pullRequestTemplates": [] } } }`), + ) + + reg.Register( + // ( is required to avoid matching projectsV2 + httpmock.GraphQL(`projects\(`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + // Register a handler to check for projects V2 just to avoid the registry panicking, even + // though we return a 500 error. This is because the project lookup is done in parallel + // so the previous error doesn't early exit. + reg.Register( + httpmock.GraphQL(`projectsV2`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + pm := &prompter.PrompterMock{} + pm.InputFunc = func(p, _ string) (string, error) { + if p == "Title (required)" { + return "Test Title", nil + } else { + return "", prompter.NoSuchPromptErr(p) + } + } + pm.MarkdownEditorFunc = func(p, _ string, ba bool) (string, error) { + if p == "Body" { + return "Test Body", nil + } else { + return "", prompter.NoSuchPromptErr(p) + } + } + pm.SelectFunc = func(p, _ string, opts []string) (int, error) { + switch p { + case "Choose a template": + return 0, nil + case "What's next?": + return prompter.IndexFor(opts, "Add metadata") + default: + return -1, prompter.NoSuchPromptErr(p) + } + } + pm.MultiSelectFunc = func(p string, _ []string, opts []string) ([]int, error) { + return prompter.IndexesFor(opts, "Projects") + } + + 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: pm, + GitClient: &git.Client{ + GhPath: "some/path/gh", + GitPath: "some/path/git", + }, + Finder: shared.NewMockFinder("feature", nil, nil), + Detector: &fd.EnabledDetectorMock{}, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{ + Name: "origin", + }, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + return "feature", nil + }, + + HeadBranch: "feature", + } + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = createRun(&opts) + + // Verify that our request contained projects + reg.Verify(t) + }) + + t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) { + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "") + cs.Register("git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "") + cs.Register(`git rev-parse --show-toplevel`, 0, "") + + // When the command is run + reg := &httpmock.Registry{} + reg.StubRepoResponse("OWNER", "REPO") + + reg.Register( + httpmock.GraphQL(`query PullRequestTemplates\b`), + httpmock.StringResponse(`{ "data": { "repository": { "pullRequestTemplates": [] } } }`), + ) + + // ( is required to avoid matching projectsV2 + reg.Exclude(t, httpmock.GraphQL(`projects\(`)) + + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + pm := &prompter.PrompterMock{} + pm.InputFunc = func(p, _ string) (string, error) { + if p == "Title (required)" { + return "Test Title", nil + } else { + return "", prompter.NoSuchPromptErr(p) + } + } + pm.MarkdownEditorFunc = func(p, _ string, ba bool) (string, error) { + if p == "Body" { + return "Test Body", nil + } else { + return "", prompter.NoSuchPromptErr(p) + } + } + pm.SelectFunc = func(p, _ string, opts []string) (int, error) { + switch p { + case "Choose a template": + return 0, nil + case "What's next?": + return prompter.IndexFor(opts, "Add metadata") + default: + return -1, prompter.NoSuchPromptErr(p) + } + } + pm.MultiSelectFunc = func(p string, _ []string, opts []string) ([]int, error) { + return prompter.IndexesFor(opts, "Projects") + } + + 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: pm, + GitClient: &git.Client{ + GhPath: "some/path/gh", + GitPath: "some/path/git", + }, + Finder: shared.NewMockFinder("feature", nil, nil), + Detector: &fd.DisabledDetectorMock{}, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + { + Remote: &git.Remote{ + Name: "origin", + }, + Repo: ghrepo.New("OWNER", "REPO"), + }, + }, nil + }, + Branch: func() (string, error) { + return "feature", nil + }, + + HeadBranch: "feature", + } + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = createRun(&opts) + + // Verify that our request did not contain projectCards + reg.Verify(t) + }) + }) + t.Run("web mode", func(t *testing.T) { t.Run("when projects v1 is supported, queries for it", func(t *testing.T) { ios, _, _, _ := iostreams.Test() @@ -2238,7 +2440,7 @@ func TestProjectsV1Deprecation(t *testing.T) { Projects: []string{"Project"}, }) - // Verify that our request contained projectCards + // Verify that our request did not contain projectCards reg.Verify(t) }) }) From 1a5b7ca60c25d0484f3d6efc419669162083da9a Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 2 May 2025 16:58:46 +0200 Subject: [PATCH 080/249] Feature detect v1 projects for preview URL As far as I can see, when there is project metadata, the preview option will never be shown in the interactive multiselect, so I don't believe this change has any functional difference. However, I did use the opportunity to drive out tests for generateCompareURL --- pkg/cmd/pr/create/create.go | 2 +- pkg/cmd/pr/create/create_test.go | 139 +++++++++++++++++++++++++++++-- 2 files changed, 134 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 64469543f..1d980b68d 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -533,7 +533,7 @@ func createRun(opts *CreateOptions) error { } } - openURL, err = generateCompareURL(*ctx, *state, gh.ProjectsV1Supported) + openURL, err = generateCompareURL(*ctx, *state, projectsV1Support) if err != nil { return err } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index ac96db0d6..bd68f19d9 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1852,11 +1852,13 @@ func mustParseQualifiedHeadRef(ref string) shared.QualifiedHeadRef { func Test_generateCompareURL(t *testing.T) { tests := []struct { - name string - ctx CreateContext - state shared.IssueMetadataState - want string - wantErr bool + name string + ctx CreateContext + state shared.IssueMetadataState + httpStubs func(*testing.T, *httpmock.Registry) + projectsV1Support gh.ProjectsV1Support + want string + wantErr bool }{ { name: "basic", @@ -1940,10 +1942,135 @@ func Test_generateCompareURL(t *testing.T) { want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1&template=story.md", wantErr: false, }, + // TODO projectsV1Deprecation + // Clean up these tests, but probably keep one for general project ID resolution. + { + name: "with projects, no v1 support", + ctx: CreateContext{ + PRRefs: &skipPushRefs{ + qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"), + baseRefs: baseRefs{ + baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + baseBranchName: "main", + }, + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + // Ensure no v1 projects are requestd + // ( is required to avoid matching projectsV2 + reg.Exclude(t, httpmock.GraphQL(`projects\(`)) + reg.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [ + { "title": "ProjectTitle", "id": "PROJECTV2ID", "resourcePath": "/OWNER/REPO/projects/3" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query UserProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "viewer": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + }, + state: shared.IssueMetadataState{ + ProjectTitles: []string{"ProjectTitle"}, + }, + projectsV1Support: gh.ProjectsV1Unsupported, + want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1&projects=OWNER%2FREPO%2F3", + wantErr: false, + }, + { + name: "with projects, v1 support", + ctx: CreateContext{ + PRRefs: &skipPushRefs{ + qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"), + baseRefs: baseRefs{ + baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"), + baseBranchName: "main", + }, + }, + }, + state: shared.IssueMetadataState{ + ProjectTitles: []string{"ProjectV1Title"}, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + // v1 project query responses + reg.Register( + httpmock.GraphQL(`query RepositoryProjectList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projects": { + "nodes": [ + { "name": "ProjectV1Title", "id": "PROJECTV1ID", "resourcePath": "/OWNER/REPO/projects/1" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectList\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projects": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + // v2 project query responses + reg.Register( + httpmock.GraphQL(`query RepositoryProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query UserProjectV2List\b`), + httpmock.StringResponse(` + { "data": { "viewer": { "projectsV2": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + }, + projectsV1Support: gh.ProjectsV1Supported, + want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1&projects=OWNER%2FREPO%2F1", + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := generateCompareURL(tt.ctx, tt.state, gh.ProjectsV1Supported) + // If http stubs are provided, register them and inject the registry into a client + // that is provided to generateCompareURL in the ctx. + if tt.httpStubs != nil { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + tt.httpStubs(t, reg) + tt.ctx.Client = api.NewClientFromHTTP(&http.Client{Transport: reg}) + } + + got, err := generateCompareURL(tt.ctx, tt.state, tt.projectsV1Support) if (err != nil) != tt.wantErr { t.Errorf("generateCompareURL() error = %v, wantErr %v", err, tt.wantErr) return From cc673cfaba6c6fd023271941fa98393b28301cf2 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 2 May 2025 14:48:07 -0600 Subject: [PATCH 081/249] test(prompter): add timeout before password input --- internal/prompter/accessible_prompter_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 00947b8f4..ed4da8de8 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -32,6 +32,9 @@ import ( // 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) { + + beforePasswordSendTimeout := 20 * time.Microsecond + t.Run("Select", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) @@ -147,6 +150,9 @@ func TestAccessiblePrompter(t *testing.T) { _, err := console.ExpectString("Enter password") require.NoError(t, err) + // Wait to ensure huh has time to set the echo mode + time.Sleep(beforePasswordSendTimeout) + // Enter a number _, err = console.SendLine(dummyPassword) require.NoError(t, err) @@ -210,6 +216,9 @@ func TestAccessiblePrompter(t *testing.T) { _, err := console.ExpectString("Paste your authentication token:") require.NoError(t, err) + // Wait to ensure huh has time to set the echo mode + time.Sleep(beforePasswordSendTimeout) + // Enter some dummy auth token _, err = console.SendLine(dummyAuthToken) require.NoError(t, err) @@ -243,6 +252,9 @@ func TestAccessiblePrompter(t *testing.T) { _, err = console.ExpectString("token is required") require.NoError(t, err) + // Wait to ensure huh has time to set the echo mode + time.Sleep(beforePasswordSendTimeout) + // Now enter some dummy auth token to return control back to the test _, err = console.SendLine(dummyAuthTokenForAfterFailure) require.NoError(t, err) From 9bc2c388da010ccd279844b6af3384da27337c43 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 6 May 2025 14:34:05 -0600 Subject: [PATCH 082/249] fix(a11y prompter): input prompt default value is readable --- internal/prompter/accessible_prompter_test.go | 20 +++++++++++++++++++ internal/prompter/prompter.go | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index ed4da8de8..8a7c058c7 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -140,6 +140,26 @@ func TestAccessiblePrompter(t *testing.T) { assert.Equal(t, dummyDefaultValue, inputValue) }) + t.Run("Input - default value is in prompt and in readable format", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + dummyDefaultValue := "12345abcdefg" + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter some characters (default: 12345abcdefg)") + require.NoError(t, err) + + // Enter nothing + _, err = console.SendLine("") + 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) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 1e4f5592a..8300059ac 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -131,7 +131,11 @@ func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, optio func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) { result := defaultValue - prompt = fmt.Sprintf("%s (%s)", prompt, defaultValue) + if defaultValue != "" { + prompt = fmt.Sprintf("%s (default: %s)", prompt, defaultValue) + } else { + prompt = fmt.Sprintf("%s:", prompt) + } form := p.newForm( huh.NewGroup( huh.NewInput(). From 2ee68411a76a62ae703fb5d03fcc8e2995406204 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 6 May 2025 14:49:09 -0600 Subject: [PATCH 083/249] fix(a11y prompter): Select prompt respects defaults --- internal/prompter/accessible_prompter_test.go | 24 +++++++++++++++++++ internal/prompter/prompter.go | 7 +++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 8a7c058c7..d4dde6358 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -5,6 +5,7 @@ package prompter_test import ( "fmt" "io" + "slices" "strings" "testing" "time" @@ -54,6 +55,29 @@ func TestAccessiblePrompter(t *testing.T) { assert.Equal(t, 0, selectValue) }) + t.Run("Select - blank input returns default value", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + dummyDefaultValue := "12345abcdefg" + options := []string{"1", "2", dummyDefaultValue} + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Input a number between 1 and 3:") + require.NoError(t, err) + + // Just press enter to accept the default + _, err = console.SendLine("") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", dummyDefaultValue, options) + require.NoError(t, err) + + expectedIndex := slices.Index(options, dummyDefaultValue) + assert.Equal(t, expectedIndex, selectValue) + }) + t.Run("MultiSelect", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 8300059ac..5c3b1238d 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -77,10 +77,15 @@ func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form { WithOutput(p.stdout) } -func (p *accessiblePrompter) Select(prompt, _ string, options []string) (int, error) { +func (p *accessiblePrompter) Select(prompt, defaultValue string, options []string) (int, error) { var result int formOptions := []huh.Option[int]{} for i, o := range options { + // If this option is the default value, assign its index + // to the result variable. huh will treat it as a default selection. + if defaultValue == o { + result = i + } formOptions = append(formOptions, huh.NewOption(o, i)) } From ab4cfb84d220b316dff439ce513adb69429ed460 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 6 May 2025 14:56:09 -0600 Subject: [PATCH 084/249] refactor(a11yprompter): shared method for prompt defaults --- internal/prompter/accessible_prompter_test.go | 2 +- internal/prompter/prompter.go | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index d4dde6358..8ae449dc9 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -171,7 +171,7 @@ func TestAccessiblePrompter(t *testing.T) { go func() { // Wait for prompt to appear - _, err := console.ExpectString("Enter some characters (default: 12345abcdefg)") + _, err := console.ExpectString("Enter some characters (default: 12345abcdefg):") require.NoError(t, err) // Enter nothing diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 5c3b1238d..4a6a4ff6d 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -77,6 +77,19 @@ func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form { WithOutput(p.stdout) } +// addDefaultsToPrompt adds default values to the prompt string. +func (p *accessiblePrompter) addDefaultsToPrompt(prompt string, defaultValues []string) string { + if len(defaultValues) == 1 { + prompt = fmt.Sprintf("%s (default: %s):", prompt, defaultValues[0]) + } else if len(defaultValues) > 1 { + prompt = fmt.Sprintf("%s (defaults: %s):", prompt, strings.Join(defaultValues, ", ")) + } else { + prompt = fmt.Sprintf("%s:", prompt) + } + + return prompt +} + func (p *accessiblePrompter) Select(prompt, defaultValue string, options []string) (int, error) { var result int formOptions := []huh.Option[int]{} @@ -136,11 +149,7 @@ func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, optio func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) { result := defaultValue - if defaultValue != "" { - prompt = fmt.Sprintf("%s (default: %s)", prompt, defaultValue) - } else { - prompt = fmt.Sprintf("%s:", prompt) - } + prompt = p.addDefaultsToPrompt(prompt, []string{defaultValue}) form := p.newForm( huh.NewGroup( huh.NewInput(). From cab906151fb142d1b000ee8be1fb3101b8087661 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 6 May 2025 15:32:30 -0600 Subject: [PATCH 085/249] fix(a11y prompter): select prompt default value is readable --- internal/prompter/accessible_prompter_test.go | 25 ++++++++++++++++++- internal/prompter/prompter.go | 12 ++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 8ae449dc9..c47969c04 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -78,6 +78,29 @@ func TestAccessiblePrompter(t *testing.T) { assert.Equal(t, expectedIndex, selectValue) }) + t.Run("Select - default value is in prompt and in readable format", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + dummyDefaultValue := "12345abcdefg" + options := []string{"1", "2", dummyDefaultValue} + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select a number (default: 12345abcdefg)") + require.NoError(t, err) + + // Just press enter to accept the default + _, err = console.SendLine("") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", dummyDefaultValue, options) + require.NoError(t, err) + + expectedIndex := slices.Index(options, dummyDefaultValue) + assert.Equal(t, expectedIndex, selectValue) + }) + t.Run("MultiSelect", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) @@ -171,7 +194,7 @@ func TestAccessiblePrompter(t *testing.T) { go func() { // Wait for prompt to appear - _, err := console.ExpectString("Enter some characters (default: 12345abcdefg):") + _, err := console.ExpectString("Enter some characters (default: 12345abcdefg)") require.NoError(t, err) // Enter nothing diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 4a6a4ff6d..c5bf04691 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -79,12 +79,15 @@ func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form { // addDefaultsToPrompt adds default values to the prompt string. func (p *accessiblePrompter) addDefaultsToPrompt(prompt string, defaultValues []string) string { + // We don't show empty default values in the prompt. + defaultValues = slices.DeleteFunc(defaultValues, func(s string) bool { + return s == "" + }) + if len(defaultValues) == 1 { - prompt = fmt.Sprintf("%s (default: %s):", prompt, defaultValues[0]) + prompt = fmt.Sprintf("%s (default: %s)", prompt, defaultValues[0]) } else if len(defaultValues) > 1 { - prompt = fmt.Sprintf("%s (defaults: %s):", prompt, strings.Join(defaultValues, ", ")) - } else { - prompt = fmt.Sprintf("%s:", prompt) + prompt = fmt.Sprintf("%s (defaults: %s)", prompt, strings.Join(defaultValues, ", ")) } return prompt @@ -93,6 +96,7 @@ func (p *accessiblePrompter) addDefaultsToPrompt(prompt string, defaultValues [] func (p *accessiblePrompter) Select(prompt, defaultValue string, options []string) (int, error) { var result int formOptions := []huh.Option[int]{} + prompt = p.addDefaultsToPrompt(prompt, []string{defaultValue}) for i, o := range options { // If this option is the default value, assign its index // to the result variable. huh will treat it as a default selection. From 957667efe6b069786584aed6034e24d543b0879d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 6 May 2025 15:33:51 -0600 Subject: [PATCH 086/249] doc(prompter): remove TODO about default value panic --- internal/prompter/prompter.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index c5bf04691..eb8e2e58f 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -126,7 +126,6 @@ func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, optio // If this option is in the defaults slice, // let's add its index to the result slice and huh // will treat it as a default selection. - // TODO: does an invalid default value constitute a panic? if slices.Contains(defaults, o) { result = append(result, i) } From 04aaaea142b831c76ddf4880829287ff60d0b761 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 6 May 2025 15:43:26 -0600 Subject: [PATCH 087/249] fix(a11y prompter): multi select defaults are readable --- internal/prompter/accessible_prompter_test.go | 31 +++++++++++++++++++ internal/prompter/prompter.go | 1 + 2 files changed, 32 insertions(+) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index c47969c04..c112c4708 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -147,6 +147,37 @@ func TestAccessiblePrompter(t *testing.T) { assert.Equal(t, []int{1}, multiSelectValue) }) + t.Run("MultiSelect - default value is in prompt and in readable format", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + dummyDefaultValues := []string{"foo", "bar"} + options := []string{"1", "2"} + options = append(options, dummyDefaultValues...) + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select a number (defaults: foo, bar)") + require.NoError(t, err) + + // Don't select anything because the defaults should be selected. + + // This confirms selections + _, err = console.SendLine("0") + require.NoError(t, err) + }() + + multiSelectValues, err := p.MultiSelect("Select a number", dummyDefaultValues, options) + require.NoError(t, err) + var expectedIndices []int + + // Get the indices of the default values within the options slice + // as that's what we expect the prompter to return when no selections are made. + for _, defaultValue := range dummyDefaultValues { + expectedIndices = append(expectedIndices, slices.Index(options, defaultValue)) + } + assert.Equal(t, expectedIndices, multiSelectValues) + }) + t.Run("Input", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index eb8e2e58f..f7a98cf98 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -121,6 +121,7 @@ func (p *accessiblePrompter) Select(prompt, defaultValue string, options []strin func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { var result []int + prompt = p.addDefaultsToPrompt(prompt, defaults) formOptions := make([]huh.Option[int], len(options)) for i, o := range options { // If this option is in the defaults slice, From 3594f6357b8db93c8cadf9d3c1726fa98d7c8ff6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 6 May 2025 15:50:57 -0600 Subject: [PATCH 088/249] fix(a11y prompter): confirm prompt default is readable --- internal/prompter/accessible_prompter_test.go | 20 +++++++++++++++++++ internal/prompter/prompter.go | 5 +++++ 2 files changed, 25 insertions(+) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index c112c4708..32a412646 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -304,6 +304,26 @@ func TestAccessiblePrompter(t *testing.T) { require.Equal(t, false, confirmValue) }) + t.Run("Confirm - default value is in prompt and in readable format", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + defaultValue := true + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Are you sure (default: yes)") + require.NoError(t, err) + + // Enter nothing + _, err = console.SendLine("") + require.NoError(t, err) + }() + + confirmValue, err := p.Confirm("Are you sure", defaultValue) + require.NoError(t, err) + require.Equal(t, defaultValue, confirmValue) + }) + t.Run("AuthToken", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index f7a98cf98..f8bd04c35 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -189,6 +189,11 @@ func (p *accessiblePrompter) Password(prompt string) (string, error) { func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) { result := defaultValue + if defaultValue { + prompt = p.addDefaultsToPrompt(prompt, []string{"yes"}) + } else { + prompt = p.addDefaultsToPrompt(prompt, []string{"no"}) + } form := p.newForm( huh.NewGroup( huh.NewConfirm(). From ee281fd9bacd77b3bdd37db127f7caf083716a71 Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Wed, 7 May 2025 17:59:22 +0500 Subject: [PATCH 089/249] Add `closedByPullRequestsReferences` JSON field to `issue view` (#10941) * [gh issue view] Expose `closedByPullRequestsReferences` JSON fields * Incorporate GitHub Copilot review suggestions * Incorporate review changes --- api/export_pr.go | 19 ++++++++- api/export_pr_test.go | 73 ++++++++++++++++++++++++++++++++- api/queries_issue.go | 22 ++++++++++ api/query_builder.go | 22 ++++++++++ pkg/cmd/issue/view/http.go | 39 ++++++++++++++++++ pkg/cmd/issue/view/view.go | 22 ++++++---- pkg/cmd/issue/view/view_test.go | 1 + 7 files changed, 187 insertions(+), 11 deletions(-) diff --git a/api/export_pr.go b/api/export_pr.go index 7ae1a4ff4..9b030c39e 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -28,6 +28,24 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} { }) } data[f] = items + case "closedByPullRequestsReferences": + items := make([]map[string]interface{}, 0, len(issue.ClosedByPullRequestsReferences.Nodes)) + for _, n := range issue.ClosedByPullRequestsReferences.Nodes { + items = append(items, map[string]interface{}{ + "id": n.ID, + "number": n.Number, + "url": n.URL, + "repository": map[string]interface{}{ + "id": n.Repository.ID, + "name": n.Repository.Name, + "owner": map[string]interface{}{ + "id": n.Repository.Owner.ID, + "login": n.Repository.Owner.Login, + }, + }, + }) + } + data[f] = items default: sf := fieldByName(v, f) data[f] = sf.Interface() @@ -143,7 +161,6 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} { items := make([]map[string]interface{}, 0, len(pr.ClosingIssuesReferences.Nodes)) for _, n := range pr.ClosingIssuesReferences.Nodes { items = append(items, map[string]interface{}{ - "id": n.ID, "number": n.Number, "url": n.URL, diff --git a/api/export_pr_test.go b/api/export_pr_test.go index 09a1dffe8..1f310693e 100644 --- a/api/export_pr_test.go +++ b/api/export_pr_test.go @@ -107,6 +107,70 @@ func TestIssue_ExportData(t *testing.T) { } `), }, + { + name: "linked pull requests", + fields: []string{"closedByPullRequestsReferences"}, + inputJSON: heredoc.Doc(` + { "closedByPullRequestsReferences": { "nodes": [ + { + "id": "I_123", + "number": 123, + "url": "https://github.com/cli/cli/pull/123", + "repository": { + "id": "R_123", + "name": "cli", + "owner": { + "id": "O_123", + "login": "cli" + } + } + }, + { + "id": "I_456", + "number": 456, + "url": "https://github.com/cli/cli/pull/456", + "repository": { + "id": "R_456", + "name": "cli", + "owner": { + "id": "O_456", + "login": "cli" + } + } + } + ] } } + `), + outputJSON: heredoc.Doc(` + { "closedByPullRequestsReferences": [ + { + "id": "I_123", + "number": 123, + "repository": { + "id": "R_123", + "name": "cli", + "owner": { + "id": "O_123", + "login": "cli" + } + }, + "url": "https://github.com/cli/cli/pull/123" + }, + { + "id": "I_456", + "number": 456, + "repository": { + "id": "R_456", + "name": "cli", + "owner": { + "id": "O_456", + "login": "cli" + } + }, + "url": "https://github.com/cli/cli/pull/456" + } + ] } + `), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -120,7 +184,14 @@ func TestIssue_ExportData(t *testing.T) { enc := json.NewEncoder(&buf) enc.SetIndent("", "\t") require.NoError(t, enc.Encode(exported)) - assert.Equal(t, tt.outputJSON, buf.String()) + + var gotData interface{} + dec = json.NewDecoder(&buf) + require.NoError(t, dec.Decode(&gotData)) + var expectData interface{} + require.NoError(t, json.Unmarshal([]byte(tt.outputJSON), &expectData)) + + assert.Equal(t, expectData, gotData) }) } } diff --git a/api/queries_issue.go b/api/queries_issue.go index 094b6b198..f09360152 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -44,6 +44,28 @@ type Issue struct { Milestone *Milestone ReactionGroups ReactionGroups IsPinned bool + + ClosedByPullRequestsReferences ClosedByPullRequestsReferences +} + +type ClosedByPullRequestsReferences struct { + Nodes []struct { + ID string + Number int + URL string + Repository struct { + ID string + Name string + Owner struct { + ID string + Login string + } + } + } + PageInfo struct { + HasNextPage bool + EndCursor string + } } // return values for Issue.Typename diff --git a/api/query_builder.go b/api/query_builder.go index 4c45da3c1..47fb4c225 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -56,6 +56,25 @@ var issueCommentLast = shortenQuery(` } `) +var issueClosedByPullRequestsReferences = shortenQuery(` + closedByPullRequestsReferences(first: 100) { + nodes { + id, + number, + url, + repository { + id, + name, + owner { + id, + login + } + } + } + pageInfo{hasNextPage,endCursor} + } +`) + var prReviewRequests = shortenQuery(` reviewRequests(first: 100) { nodes { @@ -296,6 +315,7 @@ var sharedIssuePRFields = []string{ var issueOnlyFields = []string{ "isPinned", "stateReason", + "closedByPullRequestsReferences", } var IssueFields = append(sharedIssuePRFields, issueOnlyFields...) @@ -388,6 +408,8 @@ func IssueGraphQL(fields []string) string { q = append(q, StatusCheckRollupGraphQLWithCountByState()) case "closingIssuesReferences": q = append(q, prClosingIssuesReferences) + case "closedByPullRequestsReferences": + q = append(q, issueClosedByPullRequestsReferences) default: q = append(q, field) } diff --git a/pkg/cmd/issue/view/http.go b/pkg/cmd/issue/view/http.go index e4f756436..4adc71802 100644 --- a/pkg/cmd/issue/view/http.go +++ b/pkg/cmd/issue/view/http.go @@ -53,3 +53,42 @@ func preloadIssueComments(client *http.Client, repo ghrepo.Interface, issue *api issue.Comments.PageInfo.HasNextPage = false return nil } + +func preloadClosedByPullRequestsReferences(client *http.Client, repo ghrepo.Interface, issue *api.Issue) error { + if !issue.ClosedByPullRequestsReferences.PageInfo.HasNextPage { + return nil + } + + type response struct { + Node struct { + Issue struct { + ClosedByPullRequestsReferences api.ClosedByPullRequestsReferences `graphql:"closedByPullRequestsReferences(first: 100, after: $endCursor)"` + } `graphql:"...on Issue"` + } `graphql:"node(id: $id)"` + } + + variables := map[string]interface{}{ + "id": githubv4.ID(issue.ID), + "endCursor": githubv4.String(issue.ClosedByPullRequestsReferences.PageInfo.EndCursor), + } + + gql := api.NewClientFromHTTP(client) + + for { + var query response + err := gql.Query(repo.RepoHost(), "closedByPullRequestsReferences", &query, variables) + if err != nil { + return err + } + + issue.ClosedByPullRequestsReferences.Nodes = append(issue.ClosedByPullRequestsReferences.Nodes, query.Node.Issue.ClosedByPullRequestsReferences.Nodes...) + + if !query.Node.Issue.ClosedByPullRequestsReferences.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Node.Issue.ClosedByPullRequestsReferences.PageInfo.EndCursor) + } + + issue.ClosedByPullRequestsReferences.PageInfo.HasNextPage = false + return nil +} diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index a9e25513b..3b02a3f2d 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -1,7 +1,6 @@ package view import ( - "errors" "fmt" "io" "net/http" @@ -134,6 +133,8 @@ func viewRun(opts *ViewOptions) error { opts.IO.DetectTerminalTheme() opts.IO.StartProgressIndicator() + defer opts.IO.StopProgressIndicator() + lookupFields.Add("id") issue, err := issueShared.FindIssueOrPR(httpClient, baseRepo, opts.IssueNumber, lookupFields.ToSlice()) @@ -144,18 +145,21 @@ func viewRun(opts *ViewOptions) error { if lookupFields.Contains("comments") { // FIXME: this re-fetches the comments connection even though the initial set of 100 were // fetched in the previous request. - err = preloadIssueComments(httpClient, baseRepo, issue) - } - opts.IO.StopProgressIndicator() - if err != nil { - var loadErr *issueShared.PartialLoadError - if opts.Exporter == nil && errors.As(err, &loadErr) { - fmt.Fprintf(opts.IO.ErrOut, "warning: %s\n", loadErr.Error()) - } else { + err := preloadIssueComments(httpClient, baseRepo, issue) + if err != nil { return err } } + if lookupFields.Contains("closedByPullRequestsReferences") { + err := preloadClosedByPullRequestsReferences(httpClient, baseRepo, issue) + if err != nil { + return err + } + } + + opts.IO.StopProgressIndicator() + if opts.WebMode { openURL := issue.URL if opts.IO.IsStdoutTTY() { diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index 391a288fb..71b0884a1 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -31,6 +31,7 @@ func TestJSONFields(t *testing.T) { "body", "closed", "comments", + "closedByPullRequestsReferences", "createdAt", "closedAt", "id", From 55cd3c34cddfbd2f53c59ec347d30d8f278c3708 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 7 May 2025 15:52:25 +0200 Subject: [PATCH 090/249] Feature detect v1 projects on pr edit --- pkg/cmd/pr/edit/edit.go | 5 +++ pkg/cmd/pr/edit/edit_test.go | 71 ++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 3c8d73ad3..0428d13d6 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -6,6 +6,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" shared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -19,6 +20,9 @@ import ( type EditOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams + // TODO projectsV1Deprecation + // Remove this detector since it is only used for test validation. + Detector fd.Detector Finder shared.PRFinder Surveyor Surveyor @@ -193,6 +197,7 @@ func editRun(opts *EditOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "assignees", "labels", "projectCards", "projectItems", "milestone"}, + Detector: opts.Detector, } pr, repo, err := opts.Finder.Find(findOptions) if err != nil { diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 3c4882961..b09ee104e 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" shared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -696,3 +697,73 @@ func (s testSurveyor) EditFields(e *shared.Editable, _ string) error { func (t testEditorRetriever) Retrieve() (string, error) { return "vim", nil } + +// TODO projectsV1Deprecation +// Remove this test. +func TestProjectsV1Deprecation(t *testing.T) { + t.Run("when projects v1 is supported, is included in query", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`projectCards`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + f := &cmdutil.Factory{ + IOStreams: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + } + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = editRun(&EditOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Detector: &fd.EnabledDetectorMock{}, + + Finder: shared.NewFinder(f), + + SelectorArg: "https://github.com/cli/cli/pull/123", + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) + + t.Run("when projects v1 is not supported, is not included in query", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Exclude(t, httpmock.GraphQL(`projectCards`)) + + f := &cmdutil.Factory{ + IOStreams: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + } + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = editRun(&EditOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Detector: &fd.DisabledDetectorMock{}, + + Finder: shared.NewFinder(f), + + SelectorArg: "https://github.com/cli/cli/pull/123", + }) + + // Verify that our request did not contain projectCards + reg.Verify(t) + }) +} From 2b1ec2fe16da38d0ab08c7c362817cb9dcb2fbad Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Wed, 7 May 2025 08:51:56 -0600 Subject: [PATCH 091/249] get latest go-tuf version Signed-off-by: Meredith Lancaster --- go.mod | 4 +--- go.sum | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ae517a4c2..08c9c28bb 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 - github.com/theupdateframework/go-tuf/v2 v2.0.2 + github.com/theupdateframework/go-tuf/v2 v2.1.0 github.com/zalando/go-keyring v0.2.5 golang.org/x/crypto v0.37.0 golang.org/x/sync v0.13.0 @@ -60,8 +60,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) -replace github.com/theupdateframework/go-tuf/v2 => github.com/theupdateframework/go-tuf/v2 e9e0d485966d571ea6870670d1e42553f1b3b2db - require ( dario.cat/mergo v1.0.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect diff --git a/go.sum b/go.sum index e0ecad6a7..20a708140 100644 --- a/go.sum +++ b/go.sum @@ -501,6 +501,8 @@ github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qv github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= github.com/theupdateframework/go-tuf/v2 v2.0.2 h1:PyNnjV9BJNzN1ZE6BcWK+5JbF+if370jjzO84SS+Ebo= github.com/theupdateframework/go-tuf/v2 v2.0.2/go.mod h1:baB22nBHeHBCeuGZcIlctNq4P61PcOdyARlplg5xmLA= +github.com/theupdateframework/go-tuf/v2 v2.1.0 h1:NlcIR4rftUDILPMFS3TUPvB7BPw/L8E53fNlSPoT9MY= +github.com/theupdateframework/go-tuf/v2 v2.1.0/go.mod h1:V675cQGhZONR0OGQ8r1feO0uwtsTBYPDWHzAAPn5rjE= 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= From d6068820d3990b9395f7dea084db3183737e9cd9 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Wed, 7 May 2025 09:18:30 -0600 Subject: [PATCH 092/249] get the latest go-tuf release Signed-off-by: Meredith Lancaster --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 08c9c28bb..5e65ffb21 100644 --- a/go.mod +++ b/go.mod @@ -73,6 +73,7 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/catppuccin/go v0.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.2 // 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 diff --git a/go.sum b/go.sum index 20a708140..397ab9aa4 100644 --- a/go.sum +++ b/go.sum @@ -100,6 +100,8 @@ 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/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= +github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= 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.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= From 6b226754fdf681ddb9a6cc5d8b7272deb756282b Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Wed, 7 May 2025 09:18:43 -0600 Subject: [PATCH 093/249] pass http client for use with tuf Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/trustedroot/trustedroot.go | 4 ++-- pkg/cmd/attestation/verification/sigstore.go | 6 +++--- pkg/cmd/attestation/verification/tuf.go | 4 +++- pkg/cmd/attestation/verification/tuf_test.go | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/attestation/trustedroot/trustedroot.go b/pkg/cmd/attestation/trustedroot/trustedroot.go index 4e55e27ab..7ae19616d 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot.go @@ -122,7 +122,7 @@ func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error { var tufOptions []tufConfig var defaultTR = "trusted_root.json" - tufOpt := verification.DefaultOptionsWithCacheSetting(o.None[string]()) + tufOpt := verification.DefaultOptionsWithCacheSetting(o.None[string](), nil) // Disable local caching, so we get up-to-date response from TUF repository tufOpt.CacheValidity = 0 @@ -151,7 +151,7 @@ func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error { targets: []string{defaultTR}, }) - tufOpt = verification.GitHubTUFOptions(o.None[string]()) + tufOpt = verification.GitHubTUFOptions(o.None[string](), nil) tufOpt.CacheValidity = 0 tufOptions = append(tufOptions, tufConfig{ tufOptions: tufOpt, diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 34558a4cc..169188a2e 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -73,7 +73,7 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro return liveVerifier, nil } if !config.NoPublicGood { - publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir) + publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir, config.HttpClient) if err != nil { return nil, err } @@ -350,8 +350,8 @@ func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.Si return gv, nil } -func newPublicGoodVerifier(tufMetadataDir o.Option[string]) (*verify.SignedEntityVerifier, error) { - opts := DefaultOptionsWithCacheSetting(tufMetadataDir) +func newPublicGoodVerifier(tufMetadataDir o.Option[string], hc *http.Client) (*verify.SignedEntityVerifier, error) { + opts := DefaultOptionsWithCacheSetting(tufMetadataDir, hc) client, err := tuf.New(opts) if err != nil { return nil, fmt.Errorf("failed to create TUF client: %v", err) diff --git a/pkg/cmd/attestation/verification/tuf.go b/pkg/cmd/attestation/verification/tuf.go index 94455d343..2c38f7cdf 100644 --- a/pkg/cmd/attestation/verification/tuf.go +++ b/pkg/cmd/attestation/verification/tuf.go @@ -2,9 +2,11 @@ package verification import ( _ "embed" + "net/http" "os" "path/filepath" + "github.com/cenkalti/backoff/v5" o "github.com/cli/cli/v2/pkg/option" "github.com/cli/go-gh/v2/pkg/config" "github.com/sigstore/sigstore-go/pkg/tuf" @@ -43,7 +45,7 @@ func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string], hc *http.Cl } func GitHubTUFOptions(tufMetadataDir o.Option[string], hc *http.Client) *tuf.Options { - opts := DefaultOptionsWithCacheSetting(tufMetadataDir) + opts := DefaultOptionsWithCacheSetting(tufMetadataDir, hc) opts.Root = githubRoot opts.RepositoryBaseURL = GitHubTUFMirror diff --git a/pkg/cmd/attestation/verification/tuf_test.go b/pkg/cmd/attestation/verification/tuf_test.go index e8b6ecf98..41f766ac9 100644 --- a/pkg/cmd/attestation/verification/tuf_test.go +++ b/pkg/cmd/attestation/verification/tuf_test.go @@ -12,7 +12,7 @@ import ( func TestGitHubTUFOptionsNoMetadataDir(t *testing.T) { os.Setenv("CODESPACES", "true") - opts := GitHubTUFOptions(o.None[string]()) + opts := GitHubTUFOptions(o.None[string](), nil) require.Equal(t, GitHubTUFMirror, opts.RepositoryBaseURL) require.NotNil(t, opts.Root) @@ -21,6 +21,6 @@ func TestGitHubTUFOptionsNoMetadataDir(t *testing.T) { } func TestGitHubTUFOptionsWithMetadataDir(t *testing.T) { - opts := GitHubTUFOptions(o.Some("anything")) + opts := GitHubTUFOptions(o.Some("anything"), nil) require.Equal(t, "anything", opts.CachePath) } From 8ebbd1d4bf0ad67c420d41bbe04fdf4ccb46fa15 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 7 May 2025 12:19:16 -0600 Subject: [PATCH 094/249] feat(fd): add ActorIsAssignable to IssueFeatures --- internal/featuredetection/feature_detection.go | 9 ++++++--- internal/featuredetection/feature_detection_test.go | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index fba317f58..5ff08a573 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -18,11 +18,13 @@ type Detector interface { } type IssueFeatures struct { - StateReason bool + StateReason bool + ActorIsAssignable bool } var allIssueFeatures = IssueFeatures{ - StateReason: true, + StateReason: true, + ActorIsAssignable: true, } type PullRequestFeatures struct { @@ -70,7 +72,8 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { } features := IssueFeatures{ - StateReason: false, + StateReason: false, + ActorIsAssignable: false, } var featureDetection struct { diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index f1152da2c..2c7d19071 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -23,7 +23,8 @@ func TestIssueFeatures(t *testing.T) { name: "github.com", hostname: "github.com", wantFeatures: IssueFeatures{ - StateReason: true, + StateReason: true, + ActorIsAssignable: true, }, wantErr: false, }, @@ -31,7 +32,8 @@ func TestIssueFeatures(t *testing.T) { name: "ghec data residency (ghe.com)", hostname: "stampname.ghe.com", wantFeatures: IssueFeatures{ - StateReason: true, + StateReason: true, + ActorIsAssignable: true, }, wantErr: false, }, @@ -42,7 +44,8 @@ func TestIssueFeatures(t *testing.T) { `query Issue_fields\b`: `{"data": {}}`, }, wantFeatures: IssueFeatures{ - StateReason: false, + StateReason: false, + ActorIsAssignable: false, }, wantErr: false, }, From 9092a43f20c6331087640d0c7095d55b3a178bb5 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Wed, 7 May 2025 16:41:32 -0600 Subject: [PATCH 095/249] set custom fetcher in options Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verification/tuf.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/attestation/verification/tuf.go b/pkg/cmd/attestation/verification/tuf.go index 2c38f7cdf..6faa0e13f 100644 --- a/pkg/cmd/attestation/verification/tuf.go +++ b/pkg/cmd/attestation/verification/tuf.go @@ -40,6 +40,7 @@ func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string], hc *http.Cl f.SetHTTPClient(hc) retryOptions := []backoff.RetryOption{backoff.WithMaxTries(3)} f.SetRetryOptions(retryOptions...) + opts.WithFetcher(&f) return opts } From 874c1b967aa75c4f8300baa0d600e8816568be94 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Wed, 7 May 2025 16:42:43 -0600 Subject: [PATCH 096/249] fetch http client from factory Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/inspect/inspect.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/attestation/inspect/inspect.go b/pkg/cmd/attestation/inspect/inspect.go index b571eee01..9a2bb5d3f 100644 --- a/pkg/cmd/attestation/inspect/inspect.go +++ b/pkg/cmd/attestation/inspect/inspect.go @@ -77,13 +77,18 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command opts.Hostname, _ = ghauth.DefaultHost() } - err := auth.IsHostSupported(opts.Hostname) + if err := auth.IsHostSupported(opts.Hostname); err != nil { + return err + } + + hc, err := f.HttpClient() if err != nil { return err } config := verification.SigstoreConfig{ - Logger: opts.Logger, + HttpClient: hc, + Logger: opts.Logger, } if ghauth.IsTenancy(opts.Hostname) { From 1c28a7aded34621a66c082ec31ed1419b4a175b2 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Wed, 7 May 2025 16:43:17 -0600 Subject: [PATCH 097/249] move live inspect test to integration test file Signed-off-by: Meredith Lancaster --- .../inspect/inspect_integration_test.go | 75 +++++++++++++++++++ pkg/cmd/attestation/inspect/inspect_test.go | 62 --------------- 2 files changed, 75 insertions(+), 62 deletions(-) create mode 100644 pkg/cmd/attestation/inspect/inspect_integration_test.go diff --git a/pkg/cmd/attestation/inspect/inspect_integration_test.go b/pkg/cmd/attestation/inspect/inspect_integration_test.go new file mode 100644 index 000000000..f38d0244e --- /dev/null +++ b/pkg/cmd/attestation/inspect/inspect_integration_test.go @@ -0,0 +1,75 @@ +//go:build integration + +package inspect + +import ( + "bytes" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmdutil" + + //"github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" +) + +func TestNewInspectCmd(t *testing.T) { + testIO, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: testIO, + HttpClient: func() (*http.Client, error) { + //reg := &httpmock.Registry{} + client := http.DefaultClient + //httpmock.ReplaceTripper(client, reg) + return client, nil + }, + } + + testcases := []struct { + name string + cli string + wants Options + wantsErr bool + wantsExporter bool + }{ + { + name: "Prints output in JSON format", + cli: fmt.Sprintf("%s --format json", bundlePath), + wants: Options{ + BundlePath: bundlePath, + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + }, + wantsExporter: true, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + var opts *Options + cmd := NewInspectCmd(f, func(o *Options) error { + opts = o + return nil + }) + + argv := strings.Split(tc.cli, " ") + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + _, err := cmd.ExecuteC() + if tc.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + assert.Equal(t, tc.wants.BundlePath, opts.BundlePath) + assert.NotNil(t, opts.Logger) + assert.Equal(t, tc.wantsExporter, opts.exporter != nil) + }) + } +} diff --git a/pkg/cmd/attestation/inspect/inspect_test.go b/pkg/cmd/attestation/inspect/inspect_test.go index 1e0c1305e..c1bc1c72c 100644 --- a/pkg/cmd/attestation/inspect/inspect_test.go +++ b/pkg/cmd/attestation/inspect/inspect_test.go @@ -1,11 +1,7 @@ package inspect import ( - "bytes" "encoding/json" - "fmt" - "net/http" - "strings" "testing" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" @@ -14,7 +10,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -30,63 +25,6 @@ var ( bundlePath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json") ) -func TestNewInspectCmd(t *testing.T) { - testIO, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{ - IOStreams: testIO, - HttpClient: func() (*http.Client, error) { - reg := &httpmock.Registry{} - client := &http.Client{} - httpmock.ReplaceTripper(client, reg) - return client, nil - }, - } - - testcases := []struct { - name string - cli string - wants Options - wantsErr bool - wantsExporter bool - }{ - { - name: "Prints output in JSON format", - cli: fmt.Sprintf("%s --format json", bundlePath), - wants: Options{ - BundlePath: bundlePath, - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - }, - wantsExporter: true, - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - var opts *Options - cmd := NewInspectCmd(f, func(o *Options) error { - opts = o - return nil - }) - - argv := strings.Split(tc.cli, " ") - cmd.SetArgs(argv) - cmd.SetIn(&bytes.Buffer{}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - _, err := cmd.ExecuteC() - if tc.wantsErr { - assert.Error(t, err) - return - } - assert.NoError(t, err) - - assert.Equal(t, tc.wants.BundlePath, opts.BundlePath) - assert.NotNil(t, opts.Logger) - assert.Equal(t, tc.wantsExporter, opts.exporter != nil) - }) - } -} - func TestRunInspect(t *testing.T) { opts := Options{ BundlePath: bundlePath, From 456e6416796dc9776e8906ac4785f755bd342109 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Wed, 7 May 2025 16:43:28 -0600 Subject: [PATCH 098/249] alphabetize fields Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verify/verify.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index b8debc529..90cc5643c 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -186,9 +186,10 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command opts.APIClient = api.NewLiveClient(hc, opts.Hostname, opts.Logger) config := verification.SigstoreConfig{ - TrustedRoot: opts.TrustedRoot, + HttpClient: hc, Logger: opts.Logger, NoPublicGood: opts.NoPublicGood, + TrustedRoot: opts.TrustedRoot, } // Prepare for tenancy if detected From ec5f108aa94a6117872dca994ceec866541c1bdf Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Wed, 7 May 2025 16:47:14 -0600 Subject: [PATCH 099/249] simplify test Signed-off-by: Meredith Lancaster --- .../inspect/inspect_integration_test.go | 70 ++++++------------- pkg/cmd/attestation/inspect/inspect_test.go | 4 +- 2 files changed, 21 insertions(+), 53 deletions(-) diff --git a/pkg/cmd/attestation/inspect/inspect_integration_test.go b/pkg/cmd/attestation/inspect/inspect_integration_test.go index f38d0244e..6c56461af 100644 --- a/pkg/cmd/attestation/inspect/inspect_integration_test.go +++ b/pkg/cmd/attestation/inspect/inspect_integration_test.go @@ -9,67 +9,37 @@ import ( "strings" "testing" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmdutil" - - //"github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" ) -func TestNewInspectCmd(t *testing.T) { +func TestNewInspectCmd_PrintOutputJSONFormat(t *testing.T) { testIO, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ IOStreams: testIO, HttpClient: func() (*http.Client, error) { - //reg := &httpmock.Registry{} - client := http.DefaultClient - //httpmock.ReplaceTripper(client, reg) - return client, nil + return http.DefaultClient, nil }, } - testcases := []struct { - name string - cli string - wants Options - wantsErr bool - wantsExporter bool - }{ - { - name: "Prints output in JSON format", - cli: fmt.Sprintf("%s --format json", bundlePath), - wants: Options{ - BundlePath: bundlePath, - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - }, - wantsExporter: true, - }, - } - - for _, tc := range testcases { - t.Run(tc.name, func(t *testing.T) { - var opts *Options - cmd := NewInspectCmd(f, func(o *Options) error { - opts = o - return nil - }) - - argv := strings.Split(tc.cli, " ") - cmd.SetArgs(argv) - cmd.SetIn(&bytes.Buffer{}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - _, err := cmd.ExecuteC() - if tc.wantsErr { - assert.Error(t, err) - return - } - assert.NoError(t, err) - - assert.Equal(t, tc.wants.BundlePath, opts.BundlePath) - assert.NotNil(t, opts.Logger) - assert.Equal(t, tc.wantsExporter, opts.exporter != nil) + t.Run("Print output in JSON format", func(t *testing.T) { + var opts *Options + cmd := NewInspectCmd(f, func(o *Options) error { + opts = o + return nil }) - } + + argv := strings.Split(fmt.Sprintf("%s --format json", bundlePath), " ") + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + _, err := cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, bundlePath, opts.BundlePath) + assert.NotNil(t, opts.Logger) + assert.NotNil(t, opts.exporter) + }) } diff --git a/pkg/cmd/attestation/inspect/inspect_test.go b/pkg/cmd/attestation/inspect/inspect_test.go index c1bc1c72c..c94e80ad2 100644 --- a/pkg/cmd/attestation/inspect/inspect_test.go +++ b/pkg/cmd/attestation/inspect/inspect_test.go @@ -21,9 +21,7 @@ const ( SigstoreSanRegex = "^https://github.com/sigstore/sigstore-js/" ) -var ( - bundlePath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json") -) +var bundlePath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json") func TestRunInspect(t *testing.T) { opts := Options{ From 3637f5aa26ce1b889b4f6134d6dfca26fd182a55 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Wed, 7 May 2025 17:11:18 -0600 Subject: [PATCH 100/249] add missing http client field in sigstoreConfig Signed-off-by: Meredith Lancaster --- .../attestation/verification/sigstore_integration_test.go | 6 ++++++ pkg/cmd/attestation/verify/attestation_integration_test.go | 2 ++ pkg/cmd/attestation/verify/verify_integration_test.go | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/pkg/cmd/attestation/verification/sigstore_integration_test.go b/pkg/cmd/attestation/verification/sigstore_integration_test.go index 2a2d3beea..d37b94fc8 100644 --- a/pkg/cmd/attestation/verification/sigstore_integration_test.go +++ b/pkg/cmd/attestation/verification/sigstore_integration_test.go @@ -3,6 +3,7 @@ package verification import ( + "net/http" "testing" "github.com/cli/cli/v2/pkg/cmd/attestation/api" @@ -51,6 +52,7 @@ func TestLiveSigstoreVerifier(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) @@ -71,6 +73,7 @@ func TestLiveSigstoreVerifier(t *testing.T) { t.Run("with 2/3 verified attestations", func(t *testing.T) { verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) @@ -89,6 +92,7 @@ func TestLiveSigstoreVerifier(t *testing.T) { t.Run("fail with 0/2 verified attestations", func(t *testing.T) { verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) @@ -114,6 +118,7 @@ func TestLiveSigstoreVerifier(t *testing.T) { attestations := getAttestationsFor(t, "../test/data/github_provenance_demo-0.0.12-py3-none-any-bundle.jsonl") verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) @@ -128,6 +133,7 @@ func TestLiveSigstoreVerifier(t *testing.T) { attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: io.NewTestHandler(), TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"), TUFMetadataDir: o.Some(t.TempDir()), diff --git a/pkg/cmd/attestation/verify/attestation_integration_test.go b/pkg/cmd/attestation/verify/attestation_integration_test.go index 73452c425..ec3eb271c 100644 --- a/pkg/cmd/attestation/verify/attestation_integration_test.go +++ b/pkg/cmd/attestation/verify/attestation_integration_test.go @@ -3,6 +3,7 @@ package verify import ( + "net/http" "testing" "github.com/cli/cli/v2/pkg/cmd/attestation/api" @@ -26,6 +27,7 @@ func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation { func TestVerifyAttestations(t *testing.T) { sgVerifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: io.NewTestHandler(), TUFMetadataDir: o.Some(t.TempDir()), }) diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index 92864f78e..d77f21f70 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -3,6 +3,7 @@ package verify import ( + "net/http" "testing" "github.com/cli/cli/v2/pkg/cmd/attestation/api" @@ -20,6 +21,7 @@ func TestVerifyIntegration(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: logger, TUFMetadataDir: o.Some(t.TempDir()), } @@ -136,6 +138,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: logger, TUFMetadataDir: o.Some(t.TempDir()), } @@ -209,6 +212,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: logger, TUFMetadataDir: o.Some(t.TempDir()), } @@ -301,6 +305,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ + HttpClient: http.DefaultClient, Logger: logger, TUFMetadataDir: o.Some(t.TempDir()), } From c0701c89c8c3e58ec5b62d3a1372d9915b3a181c Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Wed, 7 May 2025 17:22:17 -0600 Subject: [PATCH 101/249] include http client in verifier setup Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/trustedroot/trustedroot.go | 18 +++++++++--------- .../trustedroot/trustedroot_test.go | 15 +++++++++++++-- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/attestation/trustedroot/trustedroot.go b/pkg/cmd/attestation/trustedroot/trustedroot.go index 7ae19616d..a347b64b2 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "net/http" "os" "github.com/cli/cli/v2/pkg/cmd/attestation/api" @@ -68,6 +69,10 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com return err } + hc, err := f.HttpClient() + if err != nil { + return err + } if ghauth.IsTenancy(opts.Hostname) { c, err := f.Config() if err != nil { @@ -77,11 +82,6 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com if !c.Authentication().HasActiveToken(opts.Hostname) { return fmt.Errorf("not authenticated with %s", opts.Hostname) } - - hc, err := f.HttpClient() - if err != nil { - return err - } logger := io.NewHandler(f.IOStreams) apiClient := api.NewLiveClient(hc, opts.Hostname, logger) td, err := apiClient.GetTrustDomain() @@ -95,7 +95,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com return runF(opts) } - if err := getTrustedRoot(tuf.New, opts); err != nil { + if err := getTrustedRoot(tuf.New, opts, hc); err != nil { return fmt.Errorf("Failed to verify the TUF repository: %w", err) } @@ -118,11 +118,11 @@ type tufConfig struct { targets []string } -func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error { +func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options, hc *http.Client) error { var tufOptions []tufConfig var defaultTR = "trusted_root.json" - tufOpt := verification.DefaultOptionsWithCacheSetting(o.None[string](), nil) + tufOpt := verification.DefaultOptionsWithCacheSetting(o.None[string](), hc) // Disable local caching, so we get up-to-date response from TUF repository tufOpt.CacheValidity = 0 @@ -151,7 +151,7 @@ func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error { targets: []string{defaultTR}, }) - tufOpt = verification.GitHubTUFOptions(o.None[string](), nil) + tufOpt = verification.GitHubTUFOptions(o.None[string](), hc) tufOpt.CacheValidity = 0 tufOptions = append(tufOptions, tufConfig{ tufOptions: tufOpt, diff --git a/pkg/cmd/attestation/trustedroot/trustedroot_test.go b/pkg/cmd/attestation/trustedroot/trustedroot_test.go index c4a259436..0d67c4445 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot_test.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot_test.go @@ -28,6 +28,12 @@ func TestNewTrustedRootCmd(t *testing.T) { Config: func() (gh.Config, error) { return &ghmock.ConfigMock{}, nil }, + HttpClient: func() (*http.Client, error) { + reg := &httpmock.Registry{} + client := &http.Client{} + httpmock.ReplaceTripper(client, reg) + return client, nil + }, } testcases := []struct { @@ -113,6 +119,7 @@ func TestNewTrustedRootWithTenancy(t *testing.T) { }, }, nil }, + HttpClient: httpClientFunc, } cmd := NewTrustedRootCmd(f, func(_ *Options) error { @@ -171,15 +178,19 @@ func TestGetTrustedRoot(t *testing.T) { TufRootPath: root, } + reg := &httpmock.Registry{} + client := &http.Client{} + httpmock.ReplaceTripper(client, reg) + t.Run("failed to create TUF root", func(t *testing.T) { - err := getTrustedRoot(newTUFErrClient, opts) + err := getTrustedRoot(newTUFErrClient, opts, client) require.Error(t, err) require.ErrorContains(t, err, "failed to create TUF client") }) t.Run("fails because the root cannot be found", func(t *testing.T) { opts.TufRootPath = test.NormalizeRelativePath("./does/not/exist/root.json") - err := getTrustedRoot(tuf.New, opts) + err := getTrustedRoot(tuf.New, opts, client) require.Error(t, err) require.ErrorContains(t, err, "failed to read root file") }) From d78980c668dfe98bb35cb17ec2ea92d4db580a82 Mon Sep 17 00:00:00 2001 From: Art Leo Date: Thu, 8 May 2025 20:00:43 +1000 Subject: [PATCH 102/249] Fix release download test http stubbing Co-authored-by: William Martin --- pkg/cmd/release/download/download_test.go | 192 ++++++++++++++++++---- 1 file changed, 158 insertions(+), 34 deletions(-) diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go index 78709dd57..dab5c0a92 100644 --- a/pkg/cmd/release/download/download_test.go +++ b/pkg/cmd/release/download/download_test.go @@ -183,6 +183,7 @@ func Test_downloadRun(t *testing.T) { name string isTTY bool opts DownloadOptions + httpStubs func(*httpmock.Registry) wantErr string wantStdout string wantStderr string @@ -196,6 +197,24 @@ func Test_downloadRun(t *testing.T) { Destination: ".", Concurrency: 2, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`)) + reg.Register(httpmock.REST("GET", "assets/3456"), httpmock.StringResponse(`3456`)) + reg.Register(httpmock.REST("GET", "assets/5678"), httpmock.StringResponse(`5678`)) + }, wantStdout: ``, wantStderr: ``, wantFiles: []string{ @@ -213,6 +232,23 @@ func Test_downloadRun(t *testing.T) { Destination: "tmp/assets", Concurrency: 2, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`)) + reg.Register(httpmock.REST("GET", "assets/3456"), httpmock.StringResponse(`3456`)) + }, wantStdout: ``, wantStderr: ``, wantFiles: []string{ @@ -229,6 +265,20 @@ func Test_downloadRun(t *testing.T) { Destination: ".", Concurrency: 2, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + }, wantStdout: ``, wantStderr: ``, wantErr: "no assets match the file pattern", @@ -242,6 +292,30 @@ func Test_downloadRun(t *testing.T) { Destination: "tmp/packages", Concurrency: 2, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register( + httpmock.REST( + "GET", + "repos/OWNER/REPO/zipball/v1.2.3", + ), + httpmock.WithHeader( + httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=zipball.zip", + ), + ) + }, wantStdout: ``, wantStderr: ``, wantFiles: []string{ @@ -257,6 +331,30 @@ func Test_downloadRun(t *testing.T) { Destination: "tmp/packages", Concurrency: 2, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register( + httpmock.REST( + "GET", + "repos/OWNER/REPO/tarball/v1.2.3", + ), + httpmock.WithHeader( + httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=tarball.tgz", + ), + ) + }, wantStdout: ``, wantStderr: ``, wantFiles: []string{ @@ -273,6 +371,30 @@ func Test_downloadRun(t *testing.T) { Concurrency: 2, ArchiveType: "tar.gz", }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register( + httpmock.REST( + "GET", + "repos/OWNER/REPO/tarball/v1.2.3", + ), + httpmock.WithHeader( + httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=tarball.tgz", + ), + ) + }, wantStdout: ``, wantStderr: ``, wantFiles: []string{ @@ -289,6 +411,22 @@ func Test_downloadRun(t *testing.T) { Concurrency: 2, FilePatterns: []string{"*windows-32bit.zip"}, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`)) + }, wantStdout: ``, wantStderr: ``, wantFiles: []string{ @@ -305,6 +443,22 @@ func Test_downloadRun(t *testing.T) { Concurrency: 2, FilePatterns: []string{"*windows-32bit.zip"}, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`)) + }, wantStdout: `1234`, wantStderr: ``, }, @@ -324,41 +478,11 @@ func Test_downloadRun(t *testing.T) { ios.SetStderrTTY(tt.isTTY) fakeHTTP := &httpmock.Registry{} - shared.StubFetchRelease(t, fakeHTTP, "OWNER", "REPO", tt.opts.TagName, `{ - "assets": [ - { "name": "windows-32bit.zip", "size": 12, - "url": "https://api.github.com/assets/1234" }, - { "name": "windows-64bit.zip", "size": 34, - "url": "https://api.github.com/assets/3456" }, - { "name": "linux.tgz", "size": 56, - "url": "https://api.github.com/assets/5678" } - ], - "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", - "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" - }`) - fakeHTTP.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`)) - fakeHTTP.Register(httpmock.REST("GET", "assets/3456"), httpmock.StringResponse(`3456`)) - fakeHTTP.Register(httpmock.REST("GET", "assets/5678"), httpmock.StringResponse(`5678`)) + defer fakeHTTP.Verify(t) - fakeHTTP.Register( - httpmock.REST( - "GET", - "repos/OWNER/REPO/tarball/v1.2.3", - ), - httpmock.WithHeader( - httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=tarball.tgz", - ), - ) - - fakeHTTP.Register( - httpmock.REST( - "GET", - "repos/OWNER/REPO/zipball/v1.2.3", - ), - httpmock.WithHeader( - httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=zipball.zip", - ), - ) + if tt.httpStubs != nil { + tt.httpStubs(fakeHTTP) + } tt.opts.IO = ios tt.opts.HttpClient = func() (*http.Client, error) { From 89bed45c55557e0952cd4aacc8c66247f84d756d Mon Sep 17 00:00:00 2001 From: Art Leo Date: Thu, 8 May 2025 20:12:26 +1000 Subject: [PATCH 103/249] Release download handles missing archive URLs Co-authored-by: William Martin --- pkg/cmd/release/download/download.go | 16 +++++- pkg/cmd/release/download/download_test.go | 60 +++++++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go index cdf6135b6..b90721412 100644 --- a/pkg/cmd/release/download/download.go +++ b/pkg/cmd/release/download/download.go @@ -165,10 +165,24 @@ func downloadRun(opts *DownloadOptions) error { var toDownload []shared.ReleaseAsset isArchive := false if opts.ArchiveType != "" { - var archiveURL = release.ZipballURL + var archiveURL string if opts.ArchiveType == "tar.gz" { archiveURL = release.TarballURL + } else { + archiveURL = release.ZipballURL } + + if archiveURL == "" { + errMessage := fmt.Sprintf( + "release %q with tag %q, does not have a %q archive asset.", + release.Name, release.TagName, opts.ArchiveType, + ) + if release.IsDraft { + errMessage += " Most likely, this is because it is a draft." + } + return errors.New(errMessage) + } + // create pseudo-Asset with no name and pointing to ZipBallURL or TarBallURL toDownload = append(toDownload, shared.ReleaseAsset{APIURL: archiveURL}) isArchive = true diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go index dab5c0a92..9337c9b65 100644 --- a/pkg/cmd/release/download/download_test.go +++ b/pkg/cmd/release/download/download_test.go @@ -462,6 +462,66 @@ func Test_downloadRun(t *testing.T) { wantStdout: `1234`, wantStderr: ``, }, + { + name: "draft release with null tarball_url and zipball_url", + isTTY: true, + opts: DownloadOptions{ + TagName: "v1.2.3", + ArchiveType: "tar.gz", + Destination: "tmp/packages", + Concurrency: 2, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "tag_name": "v1.2.3", + "name": "patch-36", + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": null, + "zipball_url": null, + "draft": true + }`) + }, + wantStdout: ``, + wantStderr: ``, + wantErr: "release \"patch-36\" with tag \"v1.2.3\", does not have a \"tar.gz\" archive asset. Most likely, this is because it is a draft.", + }, + { + name: "non-draft release with null tarball_url and zipball_url", + isTTY: true, + opts: DownloadOptions{ + TagName: "v1.2.3", + ArchiveType: "tar.gz", + Destination: "tmp/packages", + Concurrency: 2, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "tag_name": "v1.2.3", + "name": "patch-36", + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": null, + "zipball_url": null, + "draft": false + }`) + }, + wantStdout: ``, + wantStderr: ``, + wantErr: "release \"patch-36\" with tag \"v1.2.3\", does not have a \"tar.gz\" archive asset.", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 9b89a0ac0e83d2191d54ea3810b4cc940d67732a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 8 May 2025 07:41:36 -0600 Subject: [PATCH 104/249] fix(a11y prompter): remove invalid defaults --- internal/prompter/accessible_prompter_test.go | 46 +++++++++++++++++++ internal/prompter/prompter.go | 21 ++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 32a412646..b2a66141a 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -101,6 +101,27 @@ func TestAccessiblePrompter(t *testing.T) { assert.Equal(t, expectedIndex, selectValue) }) + t.Run("Select - invalid defaults are excluded from prompt", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + dummyDefaultValue := "foo" + options := []string{"1", "2"} + + go func() { + // Wait for prompt to appear without the invalid default value + _, err := console.ExpectString("Select a number \r\n") + require.NoError(t, err) + + // Select option 2 + _, err = console.SendLine("2") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", dummyDefaultValue, options) + require.NoError(t, err) + assert.Equal(t, 1, selectValue) + }) + t.Run("MultiSelect", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) @@ -178,6 +199,31 @@ func TestAccessiblePrompter(t *testing.T) { assert.Equal(t, expectedIndices, multiSelectValues) }) + t.Run("MultiSelect - invalid defaults are excluded from prompt", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + dummyDefaultValues := []string{"foo", "bar"} + options := []string{"1", "2"} + + go func() { + // Wait for prompt to appear without the invalid default values + _, err := console.ExpectString("Select a number \r\n") + require.NoError(t, err) + + // Not selecting anything will fail because there are no defaults. + _, err = console.SendLine("2") + require.NoError(t, err) + + // This confirms selections + _, err = console.SendLine("0") + require.NoError(t, err) + }() + + multiSelectValues, err := p.MultiSelect("Select a number", dummyDefaultValues, options) + require.NoError(t, err) + assert.Equal(t, []int{1}, multiSelectValues) + }) + t.Run("Input", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index f8bd04c35..c2233fd92 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -79,24 +79,32 @@ func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form { // addDefaultsToPrompt adds default values to the prompt string. func (p *accessiblePrompter) addDefaultsToPrompt(prompt string, defaultValues []string) string { - // We don't show empty default values in the prompt. + // Removing empty defaults from the slice. defaultValues = slices.DeleteFunc(defaultValues, func(s string) bool { return s == "" }) + // Pluralizing the prompt if there are multiple default values. if len(defaultValues) == 1 { prompt = fmt.Sprintf("%s (default: %s)", prompt, defaultValues[0]) } else if len(defaultValues) > 1 { prompt = fmt.Sprintf("%s (defaults: %s)", prompt, strings.Join(defaultValues, ", ")) } + // Zero-length defaultValues means return prompt unchanged. return prompt } func (p *accessiblePrompter) Select(prompt, defaultValue string, options []string) (int, error) { var result int - formOptions := []huh.Option[int]{} + + // Remove invalid default values from the defaults slice. + if !slices.Contains(options, defaultValue) { + defaultValue = "" + } + prompt = p.addDefaultsToPrompt(prompt, []string{defaultValue}) + formOptions := []huh.Option[int]{} for i, o := range options { // If this option is the default value, assign its index // to the result variable. huh will treat it as a default selection. @@ -121,6 +129,12 @@ func (p *accessiblePrompter) Select(prompt, defaultValue string, options []strin func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { var result []int + + // Remove invalid default values from the defaults slice. + defaults = slices.DeleteFunc(defaults, func(s string) bool { + return !slices.Contains(options, s) + }) + prompt = p.addDefaultsToPrompt(prompt, defaults) formOptions := make([]huh.Option[int], len(options)) for i, o := range options { @@ -189,11 +203,13 @@ func (p *accessiblePrompter) Password(prompt string) (string, error) { func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) { result := defaultValue + if defaultValue { prompt = p.addDefaultsToPrompt(prompt, []string{"yes"}) } else { prompt = p.addDefaultsToPrompt(prompt, []string{"no"}) } + form := p.newForm( huh.NewGroup( huh.NewConfirm(). @@ -201,6 +217,7 @@ func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, er Value(&result), ), ) + if err := form.Run(); err != nil { return false, err } From 75f72bcf93c15e89229c13b328e1b8b65a7b0269 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Thu, 8 May 2025 14:22:05 -0600 Subject: [PATCH 105/249] use constructor Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verification/tuf.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/attestation/verification/tuf.go b/pkg/cmd/attestation/verification/tuf.go index 6faa0e13f..b88b15547 100644 --- a/pkg/cmd/attestation/verification/tuf.go +++ b/pkg/cmd/attestation/verification/tuf.go @@ -36,11 +36,11 @@ func DefaultOptionsWithCacheSetting(tufMetadataDir o.Option[string], hc *http.Cl opts.CacheValidity = 1 // configure fetcher timeout and retry - f := fetcher.DefaultFetcher{} + f := fetcher.NewDefaultFetcher() f.SetHTTPClient(hc) retryOptions := []backoff.RetryOption{backoff.WithMaxTries(3)} f.SetRetryOptions(retryOptions...) - opts.WithFetcher(&f) + opts.WithFetcher(f) return opts } From 90532e8377fca81a0a74e319b143ba37b3607ad9 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 8 May 2025 21:26:02 +0100 Subject: [PATCH 106/249] Improve assertion for disabled echo mode (#10927) * Improve assertion for disabled echo Signed-off-by: Babak K. Shandiz * Use `expect.RegExpPattern` Signed-off-by: Babak K. Shandiz --------- Signed-off-by: Babak K. Shandiz --- internal/prompter/accessible_prompter_test.go | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index ed4da8de8..9ba5d59b7 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -164,7 +164,12 @@ func TestAccessiblePrompter(t *testing.T) { // Ensure the dummy password is not printed to the screen, // asserting that echo mode is disabled. - _, err = console.ExpectString(" \r\n\r\n") + // + // Note that since console.ExpectString returns successful if the + // expected string matches any part of the stream, we have to use an + // anchored regexp (i.e., with ^ and $) to make sure the password/token + // is not printed at all. + _, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$")) require.NoError(t, err) }) @@ -230,7 +235,12 @@ func TestAccessiblePrompter(t *testing.T) { // Ensure the dummy password is not printed to the screen, // asserting that echo mode is disabled. - _, err = console.ExpectString(" \r\n\r\n") + // + // Note that since console.ExpectString returns successful if the + // expected string matches any part of the stream, we have to use an + // anchored regexp (i.e., with ^ and $) to make sure the password/token + // is not printed at all. + _, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$")) require.NoError(t, err) }) @@ -252,6 +262,10 @@ func TestAccessiblePrompter(t *testing.T) { _, err = console.ExpectString("token is required") require.NoError(t, err) + // Wait for the retry prompt + _, err = console.ExpectString("Paste your authentication token:") + require.NoError(t, err) + // Wait to ensure huh has time to set the echo mode time.Sleep(beforePasswordSendTimeout) @@ -266,7 +280,12 @@ func TestAccessiblePrompter(t *testing.T) { // Ensure the dummy password is not printed to the screen, // asserting that echo mode is disabled. - _, err = console.ExpectString(" \r\n\r\n") + // + // Note that since console.ExpectString returns successful if the + // expected string matches any part of the stream, we have to use an + // anchored regexp (i.e., with ^ and $) to make sure the password/token + // is not printed at all. + _, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$")) require.NoError(t, err) }) From 3950e84eefa163c0dbe4dc8a6acef46c5d21a0a2 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Thu, 8 May 2025 14:34:07 -0600 Subject: [PATCH 107/249] import sigstore-go at a more recent commit Signed-off-by: Meredith Lancaster --- go.mod | 24 ++++----- go.sum | 154 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 88 insertions(+), 90 deletions(-) diff --git a/go.mod b/go.mod index 5e65ffb21..77fbcff34 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ 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/cenkalti/backoff/v5 v5.0.2 github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 github.com/charmbracelet/huh v0.7.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc @@ -44,7 +45,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.2 + github.com/sigstore/sigstore-go v0.7.4-0.20250508193737-d1f9d7fb2621 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 @@ -73,7 +74,6 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/cenkalti/backoff/v5 v5.0.2 // 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 @@ -156,9 +156,9 @@ require ( 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.9 // indirect - github.com/sigstore/sigstore v1.9.1 // indirect - github.com/sigstore/timestamp-authority v1.2.5 // indirect + github.com/sigstore/rekor v1.3.10 // indirect + github.com/sigstore/sigstore v1.9.4 // indirect + github.com/sigstore/timestamp-authority v1.2.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect @@ -176,17 +176,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.34.0 // indirect - go.opentelemetry.io/otel/metric v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.34.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.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/exp v0.0.0-20240531132922-fd00a4e0eefc // indirect golang.org/x/mod v0.24.0 // indirect - golang.org/x/net v0.38.0 // indirect + golang.org/x/net v0.39.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-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect k8s.io/klog/v2 v2.130.1 // indirect ) diff --git a/go.sum b/go.sum index 397ab9aa4..018ba82bd 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,17 @@ -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 v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA= +cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q= +cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU= +cloud.google.com/go/auth v0.16.0/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= 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.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM= -cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM= +cloud.google.com/go/iam v1.5.0 h1:QlLcVMhbLGOjRcGe6VTGGTyQib8dRLK2B/kYNV0+2xs= +cloud.google.com/go/iam v1.5.0/go.mod h1:U+DOtKQltF/LxPEtcDLoobcsZMilSRwR7mgNL7knOpo= 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= +cloud.google.com/go/longrunning v0.6.6 h1:XJNDo5MUfMM05xK3ewpbSdmt7R2Zw+aQEMbdQR65Rbw= +cloud.google.com/go/longrunning v0.6.6/go.mod h1:hyeGJUrPHcx0u2Uu1UFSoYZLn4lkMrccJig0t4FI7yw= 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= @@ -20,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.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/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0/go.mod h1:kUjrAo8bgEwLeZ/CmHqNl3Z/kPm7y6FKfxxK0izYUg4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= 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/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/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= @@ -58,10 +58,10 @@ 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/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= +github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= 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= @@ -74,14 +74,14 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b 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/aws-sdk-go-v2/service/kms v1.38.3 h1:RivOtUH3eEu6SWnUMFHKAW4MqDOzWn1vGQ3S38Y5QMg= +github.com/aws/aws-sdk-go-v2/service/kms v1.38.3/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/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= @@ -229,8 +229,8 @@ github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZ 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-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= +github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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= @@ -417,8 +417,8 @@ 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.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= -github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 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.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= @@ -459,22 +459,22 @@ github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZV github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= 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.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= -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/sigstore/rekor v1.3.10 h1:/mSvRo4MZ/59ECIlARhyykAlQlkmeAQpvBPlmJtZOCU= +github.com/sigstore/rekor v1.3.10/go.mod h1:JvryKJ40O0XA48MdzYUPu0y4fyvqt0C4iSY7ri9iu3A= +github.com/sigstore/sigstore v1.9.4 h1:64+OGed80+A4mRlNzRd055vFcgBeDghjZw24rPLZgDU= +github.com/sigstore/sigstore v1.9.4/go.mod h1:Q7tGTC3gbtK7c3jcxEmGc2MmK4rRpIRzi3bxRFWKvEY= +github.com/sigstore/sigstore-go v0.7.4-0.20250508193737-d1f9d7fb2621 h1:L6Z2E0TRzVdyqlUgle8l+5vOyVdOcA3o8vAKBdi+7t8= +github.com/sigstore/sigstore-go v0.7.4-0.20250508193737-d1f9d7fb2621/go.mod h1:snTNzKeDdfjeMuaTt0gKgR3YYWvZ8yY0GIDv1SIRlHc= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.3 h1:ofTeeCNenFFqUxSziEOYh5TLMtHbHO6e8+9vT3Vf34A= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.3/go.mod h1:2D6TX/FEBMoaD86P5aYzhxRKUYPiWcOz+6EARsVnM3s= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.3 h1:2vhoi7q92JPOCrCR7AZ52lKLj1G+U+hdRnJX6/wN+qk= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.3/go.mod h1:nR4s/4sdbeHfe7RwEPL1NhwsC1ia72wDJOIMevxTMYY= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.3 h1:FtLuqkIQYvZwWWbtWHbuTbKhsILMeWnMg0VMf6xB4O4= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.3/go.mod h1:yZMHY5cEkNRkhZGGhMS6IAUgE0HcXja1xmil796wtqg= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.3 h1:f+gPRf7NVfHhJfloN672KKkNHWA7b0vAOSQZyBINHWw= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.3/go.mod h1:AjN/gspnXeMDFTOXlHzRJDs8xbkd30kH8VN9D8g4CZM= +github.com/sigstore/timestamp-authority v1.2.6 h1:e8vcpfeeJfpkj+S6sLopbLSvqIzBB6OORslxxIN3tGI= +github.com/sigstore/timestamp-authority v1.2.6/go.mod h1:X1NyVsRgiK8Bfguqe7Gr5ndyTOwAeGwU7l/vAjvF778= 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= @@ -501,8 +501,6 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= -github.com/theupdateframework/go-tuf/v2 v2.0.2 h1:PyNnjV9BJNzN1ZE6BcWK+5JbF+if370jjzO84SS+Ebo= -github.com/theupdateframework/go-tuf/v2 v2.0.2/go.mod h1:baB22nBHeHBCeuGZcIlctNq4P61PcOdyARlplg5xmLA= github.com/theupdateframework/go-tuf/v2 v2.1.0 h1:NlcIR4rftUDILPMFS3TUPvB7BPw/L8E53fNlSPoT9MY= github.com/theupdateframework/go-tuf/v2 v2.1.0/go.mod h1:V675cQGhZONR0OGQ8r1feO0uwtsTBYPDWHzAAPn5rjE= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= @@ -511,8 +509,8 @@ github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuX 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/tink-crypto/tink-go/v2 v2.4.0 h1:8VPZeZI4EeZ8P/vB6SIkhlStrJfivTJn+cQ4dtyHNh0= +github.com/tink-crypto/tink-go/v2 v2.4.0/go.mod h1:l//evrF2Y3MjdbpNDNGnKgCpo5zSmvUvnQ4MU+yE2sw= 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= @@ -533,22 +531,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.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/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= 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.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.step.sm/crypto v0.61.0 h1:rW7He7LCzhOFn9JIf/XzgTjt4Djpf1KhdXHfbXUVFpY= +go.step.sm/crypto v0.61.0/go.mod h1:rYubsWIX9j9xzi/aXXr2eFSzoTN3sklTAxJYucBqZaY= 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= @@ -559,18 +557,18 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 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/exp v0.0.0-20240531132922-fd00a4e0eefc h1:O9NuF4s+E/PvMIy+9IUZB9znFwUIXEWSstNjek6VpVg= +golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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.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/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= +golang.org/x/oauth2 v0.29.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.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= @@ -606,14 +604,14 @@ 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.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc= -google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ= +google.golang.org/api v0.229.0 h1:p98ymMtqeJ5i3lIBMj5MpR9kzIIgzpHHh8vQ+vgAzx8= +google.golang.org/api v0.229.0/go.mod h1:wyDfmq5g1wYJWn29O22FDWN48P7Xcz0xz+LBpptYvB0= 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/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1:UdXH7Kzbj+Vzastr5nVfccbmFsmYNygVLSPk1pEfDoY= +google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= From 38e52db3779e9a6a199b6799283a452bcbe0802b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 8 May 2025 11:06:34 -0600 Subject: [PATCH 108/249] feat(issue edit): fetch currently assigned actors --- api/queries_issue.go | 16 +++++++ api/queries_repo.go | 7 +++ pkg/cmd/issue/edit/edit.go | 41 +++++++++++++++--- pkg/cmd/issue/edit/edit_test.go | 75 +++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 7 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index f09360152..2b15a41b9 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -38,6 +38,7 @@ type Issue struct { Comments Comments Author Author Assignees Assignees + AssignedActors ActorAssignees Labels Labels ProjectCards ProjectCards ProjectItems ProjectItems @@ -91,6 +92,21 @@ func (a Assignees) Logins() []string { return logins } +type ActorAssignees struct { + Edges []struct { + Node Actor + } + TotalCount int +} + +func (a ActorAssignees) Logins() []string { + logins := make([]string, len(a.Edges)) + for i, a := range a.Edges { + logins[i] = a.Node.Login + } + return logins +} + type Labels struct { Nodes []IssueLabel TotalCount int diff --git a/api/queries_repo.go b/api/queries_repo.go index 93a32d80c..06ae48b12 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -146,6 +146,13 @@ type GitHubUser struct { Name string `json:"name"` } +// Actor is a superset of User and Bot +// At the time of writing, it does not support Name. +type Actor struct { + ID string `json:"id"` + Login string `json:"login"` +} + // BranchRef is the branch name in a GitHub repository type BranchRef struct { Name string `json:"name"` diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 8386cbcfa..a5694e6c9 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -197,9 +197,35 @@ func editRun(opts *EditOptions) error { } } + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + } + + issueFeatures, err := opts.Detector.IssueFeatures() + if err != nil { + return err + } + lookupFields := []string{"id", "number", "title", "body", "url"} if editable.Assignees.Edited { - lookupFields = append(lookupFields, "assignees") + if issueFeatures.ActorIsAssignable { + // At the time of writing, only 10 Actors can be assigned to an issue. + assignedActors := heredoc.Doc(` + assignedActors(first: 10) { + edges { + node { + ... on Actor { + login + } + } + } + } + `) + lookupFields = append(lookupFields, assignedActors) + } else { + lookupFields = append(lookupFields, "assignees") + } } if editable.Labels.Edited { lookupFields = append(lookupFields, "labels") @@ -207,11 +233,6 @@ func editRun(opts *EditOptions) error { if editable.Projects.Edited { // TODO projectsV1Deprecation // Remove this section as we should no longer add projectCards - if opts.Detector == nil { - cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) - } - projectsV1Support := opts.Detector.ProjectsV1() if projectsV1Support == gh.ProjectsV1Supported { lookupFields = append(lookupFields, "projectCards") @@ -254,7 +275,13 @@ func editRun(opts *EditOptions) error { editable.Title.Default = issue.Title editable.Body.Default = issue.Body - editable.Assignees.Default = issue.Assignees.Logins() + // We use Actors as the default assignees if Actors are assignable + // on this GitHub host. + if issueFeatures.ActorIsAssignable { + editable.Assignees.Default = issue.AssignedActors.Logins() + } else { + editable.Assignees.Default = issue.Assignees.Logins() + } editable.Labels.Default = issue.Labels.Names() editable.Projects.Default = append(issue.ProjectCards.ProjectNames(), issue.ProjectItems.ProjectTitles()...) projectItems := map[string]string{} diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index c9aa4c409..d0115ab4e 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -875,3 +875,78 @@ func TestProjectsV1Deprecation(t *testing.T) { reg.Verify(t) }) } + +func TestActorIsAssignable(t *testing.T) { + t.Run("when actors are assignable, query includes assignedActors", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`assignedActors`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we don't care. + _ = editRun(&EditOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Editable: prShared.Editable{ + Assignees: prShared.EditableSlice{ + Add: []string{"monalisa", "octocat"}, + Edited: true, + }, + }, + }) + + reg.Verify(t) + }) + + t.Run("when actors are not assignable, query includes assignees instead", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + // This test should NOT include assignedActors in the query + reg.Exclude(t, httpmock.GraphQL(`assignedActors`)) + // It should include the regular assignees field + reg.Register( + httpmock.GraphQL(`assignees`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we're not really interested in it. + _ = editRun(&EditOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Detector: &fd.DisabledDetectorMock{}, + IssueNumbers: []int{123}, + Editable: prShared.Editable{ + Assignees: prShared.EditableSlice{ + Add: []string{"monalisa", "octocat"}, + Edited: true, + }, + }, + }) + + reg.Verify(t) + }) +} From ee9d16920425caf41dc9f1f1ca4bd7fc388acbd4 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 9 May 2025 13:37:15 -0600 Subject: [PATCH 109/249] feat(issue edit): fetch assignable actors --- api/queries_repo.go | 141 +++++++++++++++++++++++++++----- pkg/cmd/issue/edit/edit.go | 4 +- pkg/cmd/issue/edit/edit_test.go | 97 ++++++++++++++-------- pkg/cmd/pr/edit/edit_test.go | 36 ++++---- pkg/cmd/pr/shared/editable.go | 39 +++++++-- 5 files changed, 240 insertions(+), 77 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 06ae48b12..99041e49f 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -681,13 +681,14 @@ func RepoFindForks(client *Client, repo ghrepo.Interface, limit int) ([]*Reposit } type RepoMetadataResult struct { - CurrentLogin string - AssignableUsers []RepoAssignee - Labels []RepoLabel - Projects []RepoProject - ProjectsV2 []ProjectV2 - Milestones []RepoMilestone - Teams []OrgTeam + CurrentLogin string + AssignableUsers []RepoAssignee + AssignableActors []RepoAssignee + Labels []RepoLabel + Projects []RepoProject + ProjectsV2 []ProjectV2 + Milestones []RepoMilestone + Teams []OrgTeam } func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { @@ -701,6 +702,16 @@ func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { break } } + + // Look for ID in assignable actors if not found in assignable users + for _, u := range m.AssignableActors { + if strings.EqualFold(assigneeLogin, u.Login) { + ids = append(ids, u.ID) + found = true + break + } + } + if !found { return nil, fmt.Errorf("'%s' not found", assigneeLogin) } @@ -892,12 +903,13 @@ func (m *RepoMetadataResult) Merge(m2 *RepoMetadataResult) { } type RepoMetadataInput struct { - Assignees bool - Reviewers bool - Labels bool - ProjectsV1 bool - ProjectsV2 bool - Milestones bool + Assignees bool + ActorAssignees bool + Reviewers bool + Labels bool + ProjectsV1 bool + ProjectsV2 bool + Milestones bool } // RepoMetadata pre-fetches the metadata for attaching to issues and pull requests @@ -906,14 +918,48 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput var g errgroup.Group if input.Assignees || input.Reviewers { - g.Go(func() error { - users, err := RepoAssignableUsers(client, repo) - if err != nil { - err = fmt.Errorf("error fetching assignees: %w", err) + + if input.ActorAssignees { + g.Go(func() error { + actors, err := RepoAssignableActors(client, repo) + if err != nil { + err = fmt.Errorf("error fetching assignees: %w", err) + } + result.AssignableActors = actors + return err + }) + + // If reviewers are requested, we still need to fetch the assignable users + // since commands use assignable users for reviewers too, + // but Actors are not supported for requesting review (need to confirm this). + // TODO KW: find out how to do this in the above query so we don't need to + // run two potentially expensive queries. When we fetch Actors, this + // should still return Users - Users are distinguishable from other Actors + // by having a name property. Maybe we can use the Name to filter out + // non-user Actors and populate the users list for reviewers based on + // that. + if input.Reviewers { + g.Go(func() error { + users, err := RepoAssignableUsers(client, repo) + if err != nil { + err = fmt.Errorf("error fetching assignees: %w", err) + } + result.AssignableUsers = users + return err + }) } - result.AssignableUsers = users - return err - }) + } else { + // Not using Actors, fetch legacy assignable users. + g.Go(func() error { + users, err := RepoAssignableUsers(client, repo) + if err != nil { + err = fmt.Errorf("error fetching assignees: %w", err) + } + result.AssignableUsers = users + return err + }) + } + } if input.Reviewers { @@ -1186,6 +1232,61 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee, return users, nil } +// RepoAssignableActors fetches all the assignable actors for a repository on +// GitHub hosts that support Actor assignees. +func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]RepoAssignee, error) { + type repoActorAssignee struct { + ID string + Login string + } + + type responseData struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + User RepoAssignee `graphql:"... on User"` + Bot repoActorAssignee `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "endCursor": (*githubv4.String)(nil), + } + + var actors []RepoAssignee + for { + var query responseData + err := client.Query(repo.RepoHost(), "RepositoryAssignableActors", &query, variables) + if err != nil { + return nil, err + } + + for _, node := range query.Repository.SuggestedActors.Nodes { + // Edge case if the Actor is not a Bot or a User, + // it won't be unmarshalled properly, and we'll have an + // zero value node. + if node.User.ID == "" || node.User.Login == "" { + continue + } + actors = append(actors, node.User) + } + + if !query.Repository.SuggestedActors.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor) + } + return actors, nil +} + type RepoLabel struct { ID string Name string diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index a5694e6c9..9177f60ad 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -210,6 +210,8 @@ func editRun(opts *EditOptions) error { lookupFields := []string{"id", "number", "title", "body", "url"} if editable.Assignees.Edited { if issueFeatures.ActorIsAssignable { + editable.Assignees.ActorAssignees = true + // At the time of writing, only 10 Actors can be assigned to an issue. assignedActors := heredoc.Doc(` assignedActors(first: 10) { @@ -277,7 +279,7 @@ func editRun(opts *EditOptions) error { editable.Body.Default = issue.Body // We use Actors as the default assignees if Actors are assignable // on this GitHub host. - if issueFeatures.ActorIsAssignable { + if editable.Assignees.ActorAssignees { editable.Assignees.Default = issue.AssignedActors.Logins() } else { editable.Assignees.Default = issue.Assignees.Logins() diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index d0115ab4e..01cd74900 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -118,9 +118,11 @@ func TestNewCmdEdit(t *testing.T) { output: EditOptions{ IssueNumbers: []int{23}, Editable: prShared.Editable{ - Assignees: prShared.EditableSlice{ - Add: []string{"monalisa", "hubot"}, - Edited: true, + Assignees: prShared.EditableAssignees{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Edited: true, + }, }, }, }, @@ -132,9 +134,11 @@ func TestNewCmdEdit(t *testing.T) { output: EditOptions{ IssueNumbers: []int{23}, Editable: prShared.Editable{ - Assignees: prShared.EditableSlice{ - Remove: []string{"monalisa", "hubot"}, - Edited: true, + Assignees: prShared.EditableAssignees{ + EditableSlice: prShared.EditableSlice{ + Remove: []string{"monalisa", "hubot"}, + Edited: true, + }, }, }, }, @@ -354,10 +358,12 @@ func Test_editRun(t *testing.T) { Value: "new body", Edited: true, }, - Assignees: prShared.EditableSlice{ - Add: []string{"monalisa", "hubot"}, - Remove: []string{"octocat"}, - Edited: true, + Assignees: prShared.EditableAssignees{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Remove: []string{"octocat"}, + Edited: true, + }, }, Labels: prShared.EditableSlice{ Add: []string{"feature", "TODO", "bug"}, @@ -399,10 +405,12 @@ func Test_editRun(t *testing.T) { IssueNumbers: []int{456, 123}, Interactive: false, Editable: prShared.Editable{ - Assignees: prShared.EditableSlice{ - Add: []string{"monalisa", "hubot"}, - Remove: []string{"octocat"}, - Edited: true, + Assignees: prShared.EditableAssignees{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Remove: []string{"octocat"}, + Edited: true, + }, }, Labels: prShared.EditableSlice{ Add: []string{"feature", "TODO", "bug"}, @@ -449,10 +457,12 @@ func Test_editRun(t *testing.T) { IssueNumbers: []int{123, 9999}, Interactive: false, Editable: prShared.Editable{ - Assignees: prShared.EditableSlice{ - Add: []string{"monalisa", "hubot"}, - Remove: []string{"octocat"}, - Edited: true, + Assignees: prShared.EditableAssignees{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Remove: []string{"octocat"}, + Edited: true, + }, }, Labels: prShared.EditableSlice{ Add: []string{"feature", "TODO", "bug"}, @@ -494,10 +504,12 @@ func Test_editRun(t *testing.T) { IssueNumbers: []int{123, 456}, Interactive: false, Editable: prShared.Editable{ - Assignees: prShared.EditableSlice{ - Add: []string{"monalisa", "hubot"}, - Remove: []string{"octocat"}, - Edited: true, + Assignees: prShared.EditableAssignees{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Remove: []string{"octocat"}, + Edited: true, + }, }, Milestone: prShared.EditableString{ Value: "GA", @@ -509,14 +521,14 @@ func Test_editRun(t *testing.T) { httpStubs: func(t *testing.T, reg *httpmock.Registry) { // Should only be one fetch of metadata. reg.Register( - httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + httpmock.GraphQL(`query RepositoryAssignableActors\b`), httpmock.StringResponse(` - { "data": { "repository": { "assignableUsers": { + { "data": { "repository": { "suggestedActors": { "nodes": [ { "login": "hubot", "id": "HUBOTID" }, { "login": "MonaLisa", "id": "MONAID" } ], - "pageInfo": { "hasNextPage": false } + "pageInfo": { "hasNextPage": false, "endCursor": "Mg" } } } } } `)) reg.Register( @@ -669,17 +681,30 @@ func mockIssueProjectItemsGet(_ *testing.T, reg *httpmock.Registry) { } func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) { + // reg.Register( + // httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + // httpmock.StringResponse(` + // { "data": { "repository": { "assignableUsers": { + // "nodes": [ + // { "login": "hubot", "id": "HUBOTID" }, + // { "login": "MonaLisa", "id": "MONAID" } + // ], + // "pageInfo": { "hasNextPage": false } + // } } } } + // `)) + reg.Register( - httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + httpmock.GraphQL(`query RepositoryAssignableActors\b`), httpmock.StringResponse(` - { "data": { "repository": { "assignableUsers": { + { "data": { "repository": { "suggestedActors": { "nodes": [ { "login": "hubot", "id": "HUBOTID" }, { "login": "MonaLisa", "id": "MONAID" } ], - "pageInfo": { "hasNextPage": false } + "pageInfo": { "hasNextPage": false, "endCursor": "Mg" } } } } } `)) + reg.Register( httpmock.GraphQL(`query RepositoryLabelList\b`), httpmock.StringResponse(` @@ -902,9 +927,11 @@ func TestActorIsAssignable(t *testing.T) { Detector: &fd.EnabledDetectorMock{}, IssueNumbers: []int{123}, Editable: prShared.Editable{ - Assignees: prShared.EditableSlice{ - Add: []string{"monalisa", "octocat"}, - Edited: true, + Assignees: prShared.EditableAssignees{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"monalisa", "octocat"}, + Edited: true, + }, }, }, }) @@ -940,9 +967,11 @@ func TestActorIsAssignable(t *testing.T) { Detector: &fd.DisabledDetectorMock{}, IssueNumbers: []int{123}, Editable: prShared.Editable{ - Assignees: prShared.EditableSlice{ - Add: []string{"monalisa", "octocat"}, - Edited: true, + Assignees: prShared.EditableAssignees{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"monalisa", "octocat"}, + Edited: true, + }, }, }, }) diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 3c4882961..f45984c76 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -165,9 +165,11 @@ func TestNewCmdEdit(t *testing.T) { output: EditOptions{ SelectorArg: "23", Editable: shared.Editable{ - Assignees: shared.EditableSlice{ - Add: []string{"monalisa", "hubot"}, - Edited: true, + Assignees: shared.EditableAssignees{ + EditableSlice: shared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Edited: true, + }, }, }, }, @@ -179,9 +181,11 @@ func TestNewCmdEdit(t *testing.T) { output: EditOptions{ SelectorArg: "23", Editable: shared.Editable{ - Assignees: shared.EditableSlice{ - Remove: []string{"monalisa", "hubot"}, - Edited: true, + Assignees: shared.EditableAssignees{ + EditableSlice: shared.EditableSlice{ + Remove: []string{"monalisa", "hubot"}, + Edited: true, + }, }, }, }, @@ -359,10 +363,12 @@ func Test_editRun(t *testing.T) { Remove: []string{"dependabot"}, Edited: true, }, - Assignees: shared.EditableSlice{ - Add: []string{"monalisa", "hubot"}, - Remove: []string{"octocat"}, - Edited: true, + Assignees: shared.EditableAssignees{ + EditableSlice: shared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Remove: []string{"octocat"}, + Edited: true, + }, }, Labels: shared.EditableSlice{ Add: []string{"feature", "TODO", "bug"}, @@ -413,10 +419,12 @@ func Test_editRun(t *testing.T) { Value: "base-branch-name", Edited: true, }, - Assignees: shared.EditableSlice{ - Add: []string{"monalisa", "hubot"}, - Remove: []string{"octocat"}, - Edited: true, + Assignees: shared.EditableAssignees{ + EditableSlice: shared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Remove: []string{"octocat"}, + Edited: true, + }, }, Labels: shared.EditableSlice{ Add: []string{"feature", "TODO", "bug"}, diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index e73b3c294..238939efe 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -14,7 +14,7 @@ type Editable struct { Body EditableString Base EditableString Reviewers EditableSlice - Assignees EditableSlice + Assignees EditableAssignees Labels EditableSlice Projects EditableProjects Milestone EditableString @@ -38,6 +38,13 @@ type EditableSlice struct { Allowed bool } +// EditableAssignees is a special case of EditableSlice. +// It contains a flag to indicate whether the assignees are actors or not. +type EditableAssignees struct { + EditableSlice + ActorAssignees bool +} + // ProjectsV2 mutations require a mapping of an item ID to a project ID. // Keep that map along with standard EditableSlice data. type EditableProjects struct { @@ -245,6 +252,13 @@ func (es *EditableSlice) clone() EditableSlice { return cpy } +func (ea *EditableAssignees) clone() EditableAssignees { + return EditableAssignees{ + EditableSlice: ea.EditableSlice.clone(), + ActorAssignees: ea.ActorAssignees, + } +} + func (ep *EditableProjects) clone() EditableProjects { return EditableProjects{ EditableSlice: ep.EditableSlice.clone(), @@ -378,12 +392,13 @@ func FieldsToEditSurvey(p EditPrompter, editable *Editable) error { func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable) error { input := api.RepoMetadataInput{ - Reviewers: editable.Reviewers.Edited, - Assignees: editable.Assignees.Edited, - Labels: editable.Labels.Edited, - ProjectsV1: editable.Projects.Edited, - ProjectsV2: editable.Projects.Edited, - Milestones: editable.Milestone.Edited, + Reviewers: editable.Reviewers.Edited, + Assignees: editable.Assignees.Edited, + ActorAssignees: editable.Assignees.ActorAssignees, + Labels: editable.Labels.Edited, + ProjectsV1: editable.Projects.Edited, + ProjectsV2: editable.Projects.Edited, + Milestones: editable.Milestone.Edited, } metadata, err := api.RepoMetadata(client, repo, input) if err != nil { @@ -394,6 +409,10 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable) for _, u := range metadata.AssignableUsers { users = append(users, u.Login) } + var actors []string + for _, a := range metadata.AssignableActors { + actors = append(actors, a.Login) + } var teams []string for _, t := range metadata.Teams { teams = append(teams, fmt.Sprintf("%s/%s", repo.RepoOwner(), t.Slug)) @@ -416,7 +435,11 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable) editable.Metadata = *metadata editable.Reviewers.Options = append(users, teams...) - editable.Assignees.Options = users + if editable.Assignees.ActorAssignees { + editable.Assignees.Options = actors + } else { + editable.Assignees.Options = users + } editable.Labels.Options = labels editable.Projects.Options = projects editable.Milestone.Options = milestones From 0efdfed0680c5afc8daf32da5c44928139684ca5 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 9 May 2025 22:56:09 -0600 Subject: [PATCH 110/249] feat(issue edit): support assigning actors to issues --- pkg/cmd/issue/edit/edit_test.go | 187 +++++++++++++++++------------ pkg/cmd/pr/shared/editable_http.go | 46 +++++++ 2 files changed, 154 insertions(+), 79 deletions(-) diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 01cd74900..d27bbdee6 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -394,6 +394,7 @@ func Test_editRun(t *testing.T) { mockIssueProjectItemsGet(t, reg) mockRepoMetadata(t, reg) mockIssueUpdate(t, reg) + mockIssueUpdateActorAssignees(t, reg) mockIssueUpdateLabels(t, reg) mockProjectV2ItemUpdate(t, reg) }, @@ -441,6 +442,8 @@ func Test_editRun(t *testing.T) { mockIssueProjectItemsGet(t, reg) mockIssueUpdate(t, reg) mockIssueUpdate(t, reg) + mockIssueUpdateActorAssignees(t, reg) + mockIssueUpdateActorAssignees(t, reg) mockIssueUpdateLabels(t, reg) mockIssueUpdateLabels(t, reg) mockProjectV2ItemUpdate(t, reg) @@ -546,6 +549,14 @@ func Test_editRun(t *testing.T) { mockIssueNumberGet(t, reg, 123) mockIssueNumberGet(t, reg, 456) // Updating 123 should succeed. + reg.Register( + httpmock.GraphQLMutationMatcher(`mutation ReplaceActorsForAssignable\b`, func(m map[string]interface{}) bool { + return m["assignableId"] == "123" + }), + httpmock.GraphQLMutation(` + { "data": { "replaceActorsForAssignable": { "__typename": "" } } }`, + func(inputs map[string]interface{}) {}), + ) reg.Register( httpmock.GraphQLMutationMatcher(`mutation IssueUpdate\b`, func(m map[string]interface{}) bool { return m["id"] == "123" @@ -555,6 +566,14 @@ func Test_editRun(t *testing.T) { func(inputs map[string]interface{}) {}), ) // Updating 456 should fail. + reg.Register( + httpmock.GraphQLMutationMatcher(`mutation ReplaceActorsForAssignable\b`, func(m map[string]interface{}) bool { + return m["assignableId"] == "456" + }), + httpmock.GraphQLMutation(` + { "errors": [ { "message": "test error" } ] }`, + func(inputs map[string]interface{}) {}), + ) reg.Register( httpmock.GraphQLMutationMatcher(`mutation IssueUpdate\b`, func(m map[string]interface{}) bool { return m["id"] == "456" @@ -603,6 +622,7 @@ func Test_editRun(t *testing.T) { mockIssueProjectItemsGet(t, reg) mockRepoMetadata(t, reg) mockIssueUpdate(t, reg) + mockIssueUpdateActorAssignees(t, reg) mockIssueUpdateLabels(t, reg) mockProjectV2ItemUpdate(t, reg) }, @@ -792,6 +812,15 @@ func mockIssueUpdate(t *testing.T, reg *httpmock.Registry) { ) } +func mockIssueUpdateActorAssignees(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`), + httpmock.GraphQLMutation(` + { "data": { "replaceActorsForAssignable": { "__typename": "" } } }`, + func(inputs map[string]interface{}) {}), + ) +} + func mockIssueUpdateLabels(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`mutation LabelAdd\b`), @@ -816,6 +845,85 @@ func mockProjectV2ItemUpdate(t *testing.T, reg *httpmock.Registry) { ) } +func TestActorIsAssignable(t *testing.T) { + t.Run("when actors are assignable, query includes assignedActors", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`assignedActors`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we don't care. + _ = editRun(&EditOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Editable: prShared.Editable{ + Assignees: prShared.EditableAssignees{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"monalisa", "octocat"}, + Edited: true, + }, + }, + }, + }) + + reg.Verify(t) + }) + + t.Run("when actors are not assignable, query includes assignees instead", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + // This test should NOT include assignedActors in the query + reg.Exclude(t, httpmock.GraphQL(`assignedActors`)) + // It should include the regular assignees field + reg.Register( + httpmock.GraphQL(`assignees`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + _, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + // Ignore the error because we're not really interested in it. + _ = editRun(&EditOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + Detector: &fd.DisabledDetectorMock{}, + IssueNumbers: []int{123}, + Editable: prShared.Editable{ + Assignees: prShared.EditableAssignees{ + EditableSlice: prShared.EditableSlice{ + Add: []string{"monalisa", "octocat"}, + Edited: true, + }, + }, + }, + }) + + reg.Verify(t) + }) +} + // TODO projectsV1Deprecation // Remove this test. func TestProjectsV1Deprecation(t *testing.T) { @@ -900,82 +1008,3 @@ func TestProjectsV1Deprecation(t *testing.T) { reg.Verify(t) }) } - -func TestActorIsAssignable(t *testing.T) { - t.Run("when actors are assignable, query includes assignedActors", func(t *testing.T) { - ios, _, _, _ := iostreams.Test() - - reg := &httpmock.Registry{} - reg.Register( - httpmock.GraphQL(`assignedActors`), - // Simulate a GraphQL error to early exit the test. - httpmock.StatusStringResponse(500, ""), - ) - - _, cmdTeardown := run.Stub() - defer cmdTeardown(t) - - // Ignore the error because we don't care. - _ = editRun(&EditOptions{ - IO: ios, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Detector: &fd.EnabledDetectorMock{}, - IssueNumbers: []int{123}, - Editable: prShared.Editable{ - Assignees: prShared.EditableAssignees{ - EditableSlice: prShared.EditableSlice{ - Add: []string{"monalisa", "octocat"}, - Edited: true, - }, - }, - }, - }) - - reg.Verify(t) - }) - - t.Run("when actors are not assignable, query includes assignees instead", func(t *testing.T) { - ios, _, _, _ := iostreams.Test() - - reg := &httpmock.Registry{} - // This test should NOT include assignedActors in the query - reg.Exclude(t, httpmock.GraphQL(`assignedActors`)) - // It should include the regular assignees field - reg.Register( - httpmock.GraphQL(`assignees`), - // Simulate a GraphQL error to early exit the test. - httpmock.StatusStringResponse(500, ""), - ) - - _, cmdTeardown := run.Stub() - defer cmdTeardown(t) - - // Ignore the error because we're not really interested in it. - _ = editRun(&EditOptions{ - IO: ios, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.New("OWNER", "REPO"), nil - }, - Detector: &fd.DisabledDetectorMock{}, - IssueNumbers: []int{123}, - Editable: prShared.Editable{ - Assignees: prShared.EditableAssignees{ - EditableSlice: prShared.EditableSlice{ - Add: []string{"monalisa", "octocat"}, - Edited: true, - }, - }, - }, - }) - - reg.Verify(t) - }) -} diff --git a/pkg/cmd/pr/shared/editable_http.go b/pkg/cmd/pr/shared/editable_http.go index fcc30095a..2c09f2e69 100644 --- a/pkg/cmd/pr/shared/editable_http.go +++ b/pkg/cmd/pr/shared/editable_http.go @@ -58,6 +58,26 @@ func UpdateIssue(httpClient *http.Client, repo ghrepo.Interface, id string, isPR }) } + // updateIssue mutation does not support Actors so assignment needs to + // be in a separate request when our assignees are Actors. + if options.Assignees.Edited && options.Assignees.ActorAssignees { + apiClient := api.NewClientFromHTTP(httpClient) + assigneeIds, err := options.AssigneeIds(apiClient, repo) + if err != nil { + return err + } + + // Disable the edited flag for assignees so that it doesn't + // trigger a second update in the replaceIssueFields function. + // It's important that this is done AFTER the call to + // options.AssigneeIds() above because otherwise that just exits. + options.Assignees.Edited = false + + wg.Go(func() error { + return replaceActorAssigneesForEditable(apiClient, repo, id, assigneeIds) + }) + } + if dirtyExcludingLabels(options) { wg.Go(func() error { return replaceIssueFields(httpClient, repo, id, isPR, options) @@ -67,6 +87,32 @@ func UpdateIssue(httpClient *http.Client, repo ghrepo.Interface, id string, isPR return wg.Wait() } +func replaceActorAssigneesForEditable(apiClient *api.Client, repo ghrepo.Interface, id string, assigneeIds *[]string) error { + type ReplaceActorsForAssignableInput struct { + AssignableID githubv4.ID `json:"assignableId"` + ActorIDs []githubv4.ID `json:"actorIds"` + } + + params := ReplaceActorsForAssignableInput{ + AssignableID: githubv4.ID(id), + ActorIDs: *ghIds(assigneeIds), + } + + var mutation struct { + ReplaceActorsForAssignable struct { + Typename string `graphql:"__typename"` + } `graphql:"replaceActorsForAssignable(input: $input)"` + } + + variables := map[string]interface{}{"input": params} + err := apiClient.Mutate(repo.RepoHost(), "ReplaceActorsForAssignable", &mutation, variables) + if err != nil { + return err + } + + return nil +} + func replaceIssueFields(httpClient *http.Client, repo ghrepo.Interface, id string, isPR bool, options Editable) error { apiClient := api.NewClientFromHTTP(httpClient) assigneeIds, err := options.AssigneeIds(apiClient, repo) From e0afc91fef1c3fbfe81d34c3054dbbd147e4b231 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 9 May 2025 23:02:15 -0600 Subject: [PATCH 111/249] chore(issue edit): comments cleanup --- api/queries_issue.go | 1 + pkg/cmd/issue/edit/edit_test.go | 14 +------------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index 2b15a41b9..833faaa47 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -99,6 +99,7 @@ type ActorAssignees struct { TotalCount int } +// TODO kw: Display names for actors with special display names. func (a ActorAssignees) Logins() []string { logins := make([]string, len(a.Edges)) for i, a := range a.Edges { diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index d27bbdee6..5a06614bc 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -701,18 +701,6 @@ func mockIssueProjectItemsGet(_ *testing.T, reg *httpmock.Registry) { } func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) { - // reg.Register( - // httpmock.GraphQL(`query RepositoryAssignableUsers\b`), - // httpmock.StringResponse(` - // { "data": { "repository": { "assignableUsers": { - // "nodes": [ - // { "login": "hubot", "id": "HUBOTID" }, - // { "login": "MonaLisa", "id": "MONAID" } - // ], - // "pageInfo": { "hasNextPage": false } - // } } } } - // `)) - reg.Register( httpmock.GraphQL(`query RepositoryAssignableActors\b`), httpmock.StringResponse(` @@ -721,7 +709,7 @@ func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) { { "login": "hubot", "id": "HUBOTID" }, { "login": "MonaLisa", "id": "MONAID" } ], - "pageInfo": { "hasNextPage": false, "endCursor": "Mg" } + "pageInfo": { "hasNextPage": false } } } } } `)) From 218152f7c5d454bffd019eb63e58645cd1f54639 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 9 May 2025 23:26:06 -0600 Subject: [PATCH 112/249] fix(issue edit): resolve race condition in actor assignment --- pkg/cmd/pr/shared/editable_http.go | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/pkg/cmd/pr/shared/editable_http.go b/pkg/cmd/pr/shared/editable_http.go index 2c09f2e69..1475f978c 100644 --- a/pkg/cmd/pr/shared/editable_http.go +++ b/pkg/cmd/pr/shared/editable_http.go @@ -61,19 +61,13 @@ func UpdateIssue(httpClient *http.Client, repo ghrepo.Interface, id string, isPR // updateIssue mutation does not support Actors so assignment needs to // be in a separate request when our assignees are Actors. if options.Assignees.Edited && options.Assignees.ActorAssignees { - apiClient := api.NewClientFromHTTP(httpClient) - assigneeIds, err := options.AssigneeIds(apiClient, repo) - if err != nil { - return err - } - - // Disable the edited flag for assignees so that it doesn't - // trigger a second update in the replaceIssueFields function. - // It's important that this is done AFTER the call to - // options.AssigneeIds() above because otherwise that just exits. - options.Assignees.Edited = false - wg.Go(func() error { + apiClient := api.NewClientFromHTTP(httpClient) + assigneeIds, err := options.AssigneeIds(apiClient, repo) + if err != nil { + return err + } + return replaceActorAssigneesForEditable(apiClient, repo, id, assigneeIds) }) } @@ -115,16 +109,20 @@ func replaceActorAssigneesForEditable(apiClient *api.Client, repo ghrepo.Interfa func replaceIssueFields(httpClient *http.Client, repo ghrepo.Interface, id string, isPR bool, options Editable) error { apiClient := api.NewClientFromHTTP(httpClient) - assigneeIds, err := options.AssigneeIds(apiClient, repo) - if err != nil { - return err - } projectIds, err := options.ProjectIds() if err != nil { return err } + var assigneeIds *[]string + if !options.Assignees.ActorAssignees { + assigneeIds, err = options.AssigneeIds(apiClient, repo) + if err != nil { + return err + } + } + milestoneId, err := options.MilestoneId() if err != nil { return err From 3af32fb07026c08b6c7ba4d48f818ab1365a9e3c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Sat, 10 May 2025 10:46:35 -0600 Subject: [PATCH 113/249] fix(preview): remove needless newlines --- pkg/cmd/preview/prompter/prompter.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/cmd/preview/prompter/prompter.go b/pkg/cmd/preview/prompter/prompter.go index 5b9b91f23..e07ec61d8 100644 --- a/pkg/cmd/preview/prompter/prompter.go +++ b/pkg/cmd/preview/prompter/prompter.go @@ -120,8 +120,6 @@ func prompterRun(opts *prompterOptions) error { if err := f(p); err != nil { return err } - // Newline for readability - fmt.Println() } return nil From c4ab455ebfbcc23b1f7e8483b6bdeb17d52525ba Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Sat, 10 May 2025 11:34:14 -0600 Subject: [PATCH 114/249] fix(prompter): update prompter create for changes in trunk --- pkg/cmd/preview/prompter/prompter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/preview/prompter/prompter.go b/pkg/cmd/preview/prompter/prompter.go index e07ec61d8..b63ac2d04 100644 --- a/pkg/cmd/preview/prompter/prompter.go +++ b/pkg/cmd/preview/prompter/prompter.go @@ -114,7 +114,7 @@ func prompterRun(opts *prompterOptions) error { return err } - p := prompter.New(editor, opts.IO.In, opts.IO.Out, opts.IO.ErrOut) + p := prompter.New(editor, opts.IO) for _, f := range opts.PromptsToRun { if err := f(p); err != nil { From f305cb13de20cfeb205735a2a1b315358505db1c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Sat, 10 May 2025 11:43:44 -0600 Subject: [PATCH 115/249] fix(prompter): print to iostreams stdout --- pkg/cmd/preview/prompter/prompter.go | 68 ++++++++++++++-------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/pkg/cmd/preview/prompter/prompter.go b/pkg/cmd/preview/prompter/prompter.go index b63ac2d04..5b44a5cbf 100644 --- a/pkg/cmd/preview/prompter/prompter.go +++ b/pkg/cmd/preview/prompter/prompter.go @@ -15,7 +15,7 @@ type prompterOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) - PromptsToRun []func(prompter.Prompter) error + PromptsToRun []func(prompter.Prompter, *iostreams.IOStreams) error } func NewCmdPrompter(f *cmdutil.Factory, runF func(*prompterOptions) error) *cobra.Command { @@ -36,7 +36,7 @@ func NewCmdPrompter(f *cmdutil.Factory, runF func(*prompterOptions) error) *cobr markdownEditorPrompt = "markdown-editor" ) - prompterTypeFuncMap := map[string]func(prompter.Prompter) error{ + prompterTypeFuncMap := map[string]func(prompter.Prompter, *iostreams.IOStreams) error{ selectPrompt: runSelect, multiSelectPrompt: runMultiSelect, inputPrompt: runInput, @@ -117,7 +117,7 @@ func prompterRun(opts *prompterOptions) error { p := prompter.New(editor, opts.IO) for _, f := range opts.PromptsToRun { - if err := f(p); err != nil { + if err := f(p, opts.IO); err != nil { return err } } @@ -125,112 +125,112 @@ func prompterRun(opts *prompterOptions) error { return nil } -func runSelect(p prompter.Prompter) error { - fmt.Println("Demonstrating Single Select") +func runSelect(p prompter.Prompter, io *iostreams.IOStreams) error { + fmt.Fprintln(io.Out, "Demonstrating Single Select") cuisines := []string{"Italian", "Greek", "Indian", "Japanese", "American"} favorite, err := p.Select("Favorite cuisine?", "Italian", cuisines) if err != nil { return err } - fmt.Printf("Favorite cuisine: %s\n", cuisines[favorite]) + fmt.Fprintf(io.Out, "Favorite cuisine: %s\n", cuisines[favorite]) return nil } -func runMultiSelect(p prompter.Prompter) error { - fmt.Println("Demonstrating Multi Select") +func runMultiSelect(p prompter.Prompter, io *iostreams.IOStreams) error { + fmt.Fprintln(io.Out, "Demonstrating Multi Select") cuisines := []string{"Italian", "Greek", "Indian", "Japanese", "American"} favorites, err := p.MultiSelect("Favorite cuisines?", []string{}, cuisines) if err != nil { return err } for _, f := range favorites { - fmt.Printf("Favorite cuisine: %s\n", cuisines[f]) + fmt.Fprintf(io.Out, "Favorite cuisine: %s\n", cuisines[f]) } return nil } -func runInput(p prompter.Prompter) error { - fmt.Println("Demonstrating Text Input") +func runInput(p prompter.Prompter, io *iostreams.IOStreams) error { + fmt.Fprintln(io.Out, "Demonstrating Text Input") text, err := p.Input("Favorite meal?", "Breakfast") if err != nil { return err } - fmt.Printf("You typed: %s\n", text) + fmt.Fprintf(io.Out, "You typed: %s\n", text) return nil } -func runPassword(p prompter.Prompter) error { - fmt.Println("Demonstrating Password Input") +func runPassword(p prompter.Prompter, io *iostreams.IOStreams) error { + fmt.Fprintln(io.Out, "Demonstrating Password Input") safeword, err := p.Password("Safe word?") if err != nil { return err } - fmt.Printf("Safe word: %s\n", safeword) + fmt.Fprintf(io.Out, "Safe word: %s\n", safeword) return nil } -func runConfirm(p prompter.Prompter) error { - fmt.Println("Demonstrating Confirmation") +func runConfirm(p prompter.Prompter, io *iostreams.IOStreams) error { + fmt.Fprintln(io.Out, "Demonstrating Confirmation") confirmation, err := p.Confirm("Are you sure?", true) if err != nil { return err } - fmt.Printf("Confirmation: %t\n", confirmation) + fmt.Fprintf(io.Out, "Confirmation: %t\n", confirmation) return nil } -func runAuthToken(p prompter.Prompter) error { - fmt.Println("Demonstrating Auth Token (can't be blank)") +func runAuthToken(p prompter.Prompter, io *iostreams.IOStreams) error { + fmt.Fprintln(io.Out, "Demonstrating Auth Token (can't be blank)") token, err := p.AuthToken() if err != nil { return err } - fmt.Printf("Auth token: %s\n", token) + fmt.Fprintf(io.Out, "Auth token: %s\n", token) return nil } -func runConfirmDeletion(p prompter.Prompter) error { - fmt.Println("Demonstrating Deletion Confirmation") +func runConfirmDeletion(p prompter.Prompter, io *iostreams.IOStreams) error { + fmt.Fprintln(io.Out, "Demonstrating Deletion Confirmation") err := p.ConfirmDeletion("delete-me") if err != nil { return err } - fmt.Println("Item deleted") + fmt.Fprintln(io.Out, "Item deleted") return nil } -func runInputHostname(p prompter.Prompter) error { - fmt.Println("Demonstrating Hostname") +func runInputHostname(p prompter.Prompter, io *iostreams.IOStreams) error { + fmt.Fprintln(io.Out, "Demonstrating Hostname") hostname, err := p.InputHostname() if err != nil { return err } - fmt.Printf("Hostname: %s\n", hostname) + fmt.Fprintf(io.Out, "Hostname: %s\n", hostname) return nil } -func runMarkdownEditor(p prompter.Prompter) error { +func runMarkdownEditor(p prompter.Prompter, io *iostreams.IOStreams) error { defaultText := "default text value" - fmt.Println("Demonstrating Markdown Editor with blanks allowed and default text") + fmt.Fprintln(io.Out, "Demonstrating Markdown Editor with blanks allowed and default text") editorText, err := p.MarkdownEditor("Edit your text:", defaultText, true) if err != nil { return err } - fmt.Printf("Returned text: %s\n\n", editorText) + fmt.Fprintf(io.Out, "Returned text: %s\n\n", editorText) - fmt.Println("Demonstrating Markdown Editor with blanks disallowed and default text") + fmt.Fprintln(io.Out, "Demonstrating Markdown Editor with blanks disallowed and default text") editorText2, err := p.MarkdownEditor("Edit your text:", defaultText, false) if err != nil { return err } - fmt.Printf("Returned text: %s\n\n", editorText2) + fmt.Fprintf(io.Out, "Returned text: %s\n\n", editorText2) - fmt.Println("Demonstrating Markdown Editor with blanks disallowed and no default text") + fmt.Fprintln(io.Out, "Demonstrating Markdown Editor with blanks disallowed and no default text") editorText3, err := p.MarkdownEditor("Edit your text:", "", false) if err != nil { return err } - fmt.Printf("Returned text: %s\n", editorText3) + fmt.Fprintf(io.Out, "Returned text: %s\n", editorText3) return nil } From 175767cc59db46e2ac235859af6c12a57248bf58 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Sat, 10 May 2025 11:54:32 -0600 Subject: [PATCH 116/249] doc(preview): add long description --- pkg/cmd/preview/preview.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/cmd/preview/preview.go b/pkg/cmd/preview/preview.go index 5c3209722..791c100b6 100644 --- a/pkg/cmd/preview/preview.go +++ b/pkg/cmd/preview/preview.go @@ -1,6 +1,7 @@ package preview import ( + "github.com/MakeNowJust/heredoc" cmdPrompter "github.com/cli/cli/v2/pkg/cmd/preview/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -10,6 +11,10 @@ func NewCmdPreview(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "preview ", Short: "Execute previews for gh features", + Long: heredoc.Doc(` + Preview commands are for testing, demonstrative, and development purposes only. + They should be considered unstable and can change at any time. + `), } cmdutil.DisableAuthCheck(cmd) From 4985d0ff44e8f45caf1c77e0551077bfd4a9c893 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 12 May 2025 15:06:11 +0100 Subject: [PATCH 117/249] fix bug when removing all PR reviwers Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/edit/edit.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 0428d13d6..cc5eefb9e 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -283,8 +283,7 @@ func updatePullRequestReviews(httpClient *http.Client, repo ghrepo.Interface, id if err != nil { return err } - if (userIds == nil || len(*userIds) == 0) && - (teamIds == nil || len(*teamIds) == 0) { + if userIds == nil && teamIds == nil { return nil } union := githubv4.Boolean(false) From ce710aa24928db04adb97b6d7b8e47a10092be32 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 12 May 2025 15:15:43 +0100 Subject: [PATCH 118/249] Add test to verify removing all reviewers Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/edit/edit_test.go | 88 +++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index b09ee104e..ed2231c5d 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -446,6 +446,64 @@ func Test_editRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/pull/123\n", }, + { + name: "non-interactive remove all reviewers", + input: &EditOptions{ + SelectorArg: "123", + Finder: shared.NewMockFinder("123", &api.PullRequest{ + URL: "https://github.com/OWNER/REPO/pull/123", + }, ghrepo.New("OWNER", "REPO")), + Interactive: false, + Editable: shared.Editable{ + Title: shared.EditableString{ + Value: "new title", + Edited: true, + }, + Body: shared.EditableString{ + Value: "new body", + Edited: true, + }, + Base: shared.EditableString{ + Value: "base-branch-name", + Edited: true, + }, + Reviewers: shared.EditableSlice{ + Remove: []string{"OWNER/core", "OWNER/external", "monalisa", "hubot", "dependabot"}, + Edited: true, + }, + Assignees: shared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Remove: []string{"octocat"}, + Edited: true, + }, + Labels: shared.EditableSlice{ + Add: []string{"feature", "TODO", "bug"}, + Remove: []string{"docs"}, + Edited: true, + }, + Projects: shared.EditableProjects{ + EditableSlice: shared.EditableSlice{ + Add: []string{"Cleanup", "CleanupV2"}, + Remove: []string{"Roadmap", "RoadmapV2"}, + Edited: true, + }, + }, + Milestone: shared.EditableString{ + Value: "GA", + Edited: true, + }, + }, + Fetcher: testFetcher{}, + }, + httpStubs: func(reg *httpmock.Registry) { + mockRepoMetadata(reg, false) + mockPullRequestUpdate(reg) + mockPullRequestReviewersUpdate(reg) + mockPullRequestUpdateLabels(reg) + mockProjectV2ItemUpdate(reg) + }, + stdout: "https://github.com/OWNER/REPO/pull/123\n", + }, { name: "interactive", input: &EditOptions{ @@ -487,6 +545,27 @@ func Test_editRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/pull/123\n", }, + { + name: "interactive remove all reviewers", + input: &EditOptions{ + SelectorArg: "123", + Finder: shared.NewMockFinder("123", &api.PullRequest{ + URL: "https://github.com/OWNER/REPO/pull/123", + }, ghrepo.New("OWNER", "REPO")), + Interactive: true, + Surveyor: testSurveyor{removeAllReviewers: true}, + Fetcher: testFetcher{}, + EditorRetriever: testEditorRetriever{}, + }, + httpStubs: func(reg *httpmock.Registry) { + mockRepoMetadata(reg, false) + mockPullRequestUpdate(reg) + mockPullRequestReviewersUpdate(reg) + mockPullRequestUpdateLabels(reg) + mockProjectV2ItemUpdate(reg) + }, + stdout: "https://github.com/OWNER/REPO/pull/123\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -658,7 +737,8 @@ func mockProjectV2ItemUpdate(reg *httpmock.Registry) { type testFetcher struct{} type testSurveyor struct { - skipReviewers bool + skipReviewers bool + removeAllReviewers bool } type testEditorRetriever struct{} @@ -683,7 +763,11 @@ func (s testSurveyor) EditFields(e *shared.Editable, _ string) error { e.Title.Value = "new title" e.Body.Value = "new body" if !s.skipReviewers { - e.Reviewers.Value = []string{"monalisa", "hubot", "OWNER/core", "OWNER/external"} + if s.removeAllReviewers { + e.Reviewers.Remove = []string{"monalisa", "hubot", "OWNER/core", "OWNER/external"} + } else { + e.Reviewers.Value = []string{"monalisa", "hubot", "OWNER/core", "OWNER/external"} + } } e.Assignees.Value = []string{"monalisa", "hubot"} e.Labels.Value = []string{"feature", "TODO", "bug"} From 29241cb7a51a8d672bfe00c706cb9ea09f84b6f4 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 12 May 2025 11:34:31 -0600 Subject: [PATCH 119/249] refactor(issue edit): improve actor type handling This improves actor type handling while fetching repository assignable actors. --- api/queries_repo.go | 37 +++++++++++++++++++++++---------- pkg/cmd/issue/edit/edit_test.go | 8 +++---- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 99041e49f..a5b926fc0 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -1235,17 +1235,25 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee, // RepoAssignableActors fetches all the assignable actors for a repository on // GitHub hosts that support Actor assignees. func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]RepoAssignee, error) { - type repoActorAssignee struct { - ID string - Login string + type repoBotAssignee struct { + ID string + Login string + TypeName string `graphql:"__typename"` + } + + type repoUserAssignee struct { + ID string + Login string + Name string + TypeName string `graphql:"__typename"` } type responseData struct { Repository struct { SuggestedActors struct { Nodes []struct { - User RepoAssignee `graphql:"... on User"` - Bot repoActorAssignee `graphql:"... on Bot"` + User repoUserAssignee `graphql:"... on User"` + Bot repoBotAssignee `graphql:"... on Bot"` } PageInfo struct { HasNextPage bool @@ -1270,13 +1278,20 @@ func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]RepoAssignee } for _, node := range query.Repository.SuggestedActors.Nodes { - // Edge case if the Actor is not a Bot or a User, - // it won't be unmarshalled properly, and we'll have an - // zero value node. - if node.User.ID == "" || node.User.Login == "" { - continue + if node.User.TypeName == "User" { + actor := RepoAssignee{ + ID: node.User.ID, + Login: node.User.Login, + Name: node.User.Name, + } + actors = append(actors, actor) + } else if node.Bot.TypeName == "Bot" { + actor := RepoAssignee{ + ID: node.Bot.ID, + Login: node.Bot.Login, + } + actors = append(actors, actor) } - actors = append(actors, node.User) } if !query.Repository.SuggestedActors.PageInfo.HasNextPage { diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 5a06614bc..bf2a1c417 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -528,8 +528,8 @@ func Test_editRun(t *testing.T) { httpmock.StringResponse(` { "data": { "repository": { "suggestedActors": { "nodes": [ - { "login": "hubot", "id": "HUBOTID" }, - { "login": "MonaLisa", "id": "MONAID" } + { "login": "hubot", "id": "HUBOTID", "__typename": "Bot" }, + { "login": "MonaLisa", "id": "MONAID", "__typename": "User" } ], "pageInfo": { "hasNextPage": false, "endCursor": "Mg" } } } } } @@ -706,8 +706,8 @@ func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) { httpmock.StringResponse(` { "data": { "repository": { "suggestedActors": { "nodes": [ - { "login": "hubot", "id": "HUBOTID" }, - { "login": "MonaLisa", "id": "MONAID" } + { "login": "hubot", "id": "HUBOTID", "__typename": "Bot" }, + { "login": "MonaLisa", "id": "MONAID", "__typename": "User" } ], "pageInfo": { "hasNextPage": false } } } } } From 73e5589059eebefedbc9116bae7adaad1c8f8768 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 12 May 2025 11:37:37 -0600 Subject: [PATCH 120/249] doc(issue): comment why assignable actors disabled --- internal/featuredetection/feature_detection.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 5ff08a573..a2f34a60b 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -73,7 +73,7 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { features := IssueFeatures{ StateReason: false, - ActorIsAssignable: false, + ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES } var featureDetection struct { From cff2fa71e2b044befe0ea865b174cd08e9ab91d6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 12 May 2025 11:49:44 -0600 Subject: [PATCH 121/249] doc(repo queries): clarify reviewer actor fetching logic --- api/queries_repo.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index a5b926fc0..6b9f690eb 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -929,7 +929,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput return err }) - // If reviewers are requested, we still need to fetch the assignable users + // If reviewers are also requested, we still need to fetch the assignable users // since commands use assignable users for reviewers too, // but Actors are not supported for requesting review (need to confirm this). // TODO KW: find out how to do this in the above query so we don't need to @@ -938,6 +938,9 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput // by having a name property. Maybe we can use the Name to filter out // non-user Actors and populate the users list for reviewers based on // that. + // Note: this only matters for `gh pr` flows, which currently does not + // request actor assignees, so we probably won't hit this until + // `gh pr` reqeuests actor assignees. if input.Reviewers { g.Go(func() error { users, err := RepoAssignableUsers(client, repo) From 5dca16fd4114fe79eae276fec9b2c244cd578753 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 13 May 2025 10:13:45 +0100 Subject: [PATCH 122/249] Update test case for removing all reviewers Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/edit/edit_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index ed2231c5d..63e380486 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -764,7 +764,7 @@ func (s testSurveyor) EditFields(e *shared.Editable, _ string) error { e.Body.Value = "new body" if !s.skipReviewers { if s.removeAllReviewers { - e.Reviewers.Remove = []string{"monalisa", "hubot", "OWNER/core", "OWNER/external"} + e.Reviewers.Remove = []string{"monalisa", "hubot", "OWNER/core", "OWNER/external", "dependabot"} } else { e.Reviewers.Value = []string{"monalisa", "hubot", "OWNER/core", "OWNER/external"} } From 5db8cf7c1db7487038647da2e57a75a460839694 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 13 May 2025 12:38:45 +0100 Subject: [PATCH 123/249] Increase `beforePasswordSendTimeout` to 100 us (#10977) Signed-off-by: Babak K. Shandiz --- internal/prompter/accessible_prompter_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index ec19a43e0..2b8104e9a 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -34,7 +34,7 @@ import ( // but doesn't mandate that prompts always look exactly the same. func TestAccessiblePrompter(t *testing.T) { - beforePasswordSendTimeout := 20 * time.Microsecond + beforePasswordSendTimeout := 100 * time.Microsecond t.Run("Select", func(t *testing.T) { console := newTestVirtualTerminal(t) From 35792827ad3e0bd142bf91d2dc38fac9195fa526 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 13 May 2025 07:09:04 -0600 Subject: [PATCH 124/249] refactor(issue edit): rename AssignedActors to ActorAssignees --- api/queries_issue.go | 2 +- pkg/cmd/issue/edit/edit.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index 833faaa47..69e93c974 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -38,7 +38,7 @@ type Issue struct { Comments Comments Author Author Assignees Assignees - AssignedActors ActorAssignees + ActorAssignees ActorAssignees Labels Labels ProjectCards ProjectCards ProjectItems ProjectItems diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 9177f60ad..b86f20550 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -280,7 +280,7 @@ func editRun(opts *EditOptions) error { // We use Actors as the default assignees if Actors are assignable // on this GitHub host. if editable.Assignees.ActorAssignees { - editable.Assignees.Default = issue.AssignedActors.Logins() + editable.Assignees.Default = issue.ActorAssignees.Logins() } else { editable.Assignees.Default = issue.Assignees.Logins() } From 3bed77836a5ff2f95dab3d01f5c819d19c729bd3 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 13 May 2025 07:11:24 -0600 Subject: [PATCH 125/249] refactor(issue edit): rename loop variable for clarity --- api/queries_repo.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 6b9f690eb..ea60319a3 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -704,9 +704,9 @@ func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { } // Look for ID in assignable actors if not found in assignable users - for _, u := range m.AssignableActors { - if strings.EqualFold(assigneeLogin, u.Login) { - ids = append(ids, u.ID) + for _, a := range m.AssignableActors { + if strings.EqualFold(assigneeLogin, a.Login) { + ids = append(ids, a.ID) found = true break } From 261297f0a2ccb0322c4dce8bb2fc99ae3e5c0c49 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 13 May 2025 07:28:42 -0600 Subject: [PATCH 126/249] refactor(issue edit): add assignedActors to lookupFields --- api/query_builder.go | 2 ++ pkg/cmd/issue/edit/edit.go | 15 +-------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/api/query_builder.go b/api/query_builder.go index 47fb4c225..0ef44a347 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -366,6 +366,8 @@ func IssueGraphQL(fields []string) string { q = append(q, `headRepository{id,name}`) case "assignees": q = append(q, `assignees(first:100){nodes{id,login,name},totalCount}`) + case "assignedActors": + q = append(q, `assignedActors(first: 10){edges{node{...on Actor{login}}},totalCount}`) case "labels": q = append(q, `labels(first:100){nodes{id,name,description,color},totalCount}`) case "projectCards": diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index b86f20550..a2e824ebf 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -211,20 +211,7 @@ func editRun(opts *EditOptions) error { if editable.Assignees.Edited { if issueFeatures.ActorIsAssignable { editable.Assignees.ActorAssignees = true - - // At the time of writing, only 10 Actors can be assigned to an issue. - assignedActors := heredoc.Doc(` - assignedActors(first: 10) { - edges { - node { - ... on Actor { - login - } - } - } - } - `) - lookupFields = append(lookupFields, assignedActors) + lookupFields = append(lookupFields, `assignedActors`) } else { lookupFields = append(lookupFields, "assignees") } From b70f9c1ddaaea026c0795d2058e0063b9438995b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 13 May 2025 14:30:32 +0100 Subject: [PATCH 127/249] Add example usage of `--head` option Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/list/list.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 0b86d5e11..8837c66fb 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -64,6 +64,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman # List PRs authored by you $ gh pr list --author "@me" + # List PRs with a specific head branch name + $ gh pr list --head "typo" + # List only PRs with all of the given labels $ gh pr list --label bug --label "priority 1" From 59a585de9ca3cf0a71b9d2e4b7b12d37693cb6b2 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 13 May 2025 14:52:13 +0100 Subject: [PATCH 128/249] Mention created PR's URL will be printed upon success Signed-off-by: Babak K. Shandiz --- 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 1d980b68d..705f46023 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -201,6 +201,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co Long: heredoc.Docf(` Create a pull request on GitHub. + Upon success, the URL of the created pull request will be printed. + When the current branch isn't fully pushed to a git remote, a prompt will ask where 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. From 21bd797c6c31859d65edc37cdb6b0fa82bbb1f68 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 13 May 2025 11:45:40 -0600 Subject: [PATCH 129/249] fix(issue edit): use double quotes for assignedActors --- pkg/cmd/issue/edit/edit.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index a2e824ebf..c9c76746b 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -211,7 +211,7 @@ func editRun(opts *EditOptions) error { if editable.Assignees.Edited { if issueFeatures.ActorIsAssignable { editable.Assignees.ActorAssignees = true - lookupFields = append(lookupFields, `assignedActors`) + lookupFields = append(lookupFields, "assignedActors") } else { lookupFields = append(lookupFields, "assignees") } From 1e5c3c7426144987f7dece2913c799ad7ca9a454 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 13 May 2025 13:02:20 -0600 Subject: [PATCH 130/249] fix(issue edit): revert rename of ActorAssignees --- api/queries_issue.go | 6 +++--- pkg/cmd/issue/edit/edit.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index 69e93c974..701b92039 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -38,7 +38,7 @@ type Issue struct { Comments Comments Author Author Assignees Assignees - ActorAssignees ActorAssignees + AssignedActors AssignedActors Labels Labels ProjectCards ProjectCards ProjectItems ProjectItems @@ -92,7 +92,7 @@ func (a Assignees) Logins() []string { return logins } -type ActorAssignees struct { +type AssignedActors struct { Edges []struct { Node Actor } @@ -100,7 +100,7 @@ type ActorAssignees struct { } // TODO kw: Display names for actors with special display names. -func (a ActorAssignees) Logins() []string { +func (a AssignedActors) Logins() []string { logins := make([]string, len(a.Edges)) for i, a := range a.Edges { logins[i] = a.Node.Login diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index c9c76746b..5cb789543 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -267,7 +267,7 @@ func editRun(opts *EditOptions) error { // We use Actors as the default assignees if Actors are assignable // on this GitHub host. if editable.Assignees.ActorAssignees { - editable.Assignees.Default = issue.ActorAssignees.Logins() + editable.Assignees.Default = issue.AssignedActors.Logins() } else { editable.Assignees.Default = issue.Assignees.Logins() } From dfd600713f001e3c1385ee9ea21597d78eaca535 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 14 May 2025 08:53:47 -0600 Subject: [PATCH 131/249] refactor(api): rename assignable user types and methods --- api/queries_repo.go | 148 +++++++++++++++++++++++-------- api/queries_repo_test.go | 6 +- pkg/cmd/pr/shared/completion.go | 8 +- pkg/cmd/pr/shared/editable.go | 4 +- pkg/cmd/pr/shared/survey.go | 2 +- pkg/cmd/pr/shared/survey_test.go | 6 +- 6 files changed, 123 insertions(+), 51 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index ea60319a3..9e822767d 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -682,8 +682,8 @@ func RepoFindForks(client *Client, repo ghrepo.Interface, limit int) ([]*Reposit type RepoMetadataResult struct { CurrentLogin string - AssignableUsers []RepoAssignee - AssignableActors []RepoAssignee + AssignableUsers []AssignableUser + AssignableActors []AssignableActor Labels []RepoLabel Projects []RepoProject ProjectsV2 []ProjectV2 @@ -696,8 +696,8 @@ func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { for _, assigneeLogin := range names { found := false for _, u := range m.AssignableUsers { - if strings.EqualFold(assigneeLogin, u.Login) { - ids = append(ids, u.ID) + if strings.EqualFold(assigneeLogin, (u.Login())) { + ids = append(ids, u.ID()) found = true break } @@ -705,8 +705,8 @@ func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { // Look for ID in assignable actors if not found in assignable users for _, a := range m.AssignableActors { - if strings.EqualFold(assigneeLogin, a.Login) { - ids = append(ids, a.ID) + if strings.EqualFold(assigneeLogin, a.Login()) { + ids = append(ids, a.ID()) found = true break } @@ -1126,12 +1126,16 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes result.Teams = append(result.Teams, t) } default: - user := RepoAssignee{} + user := struct { + Id string + Login string + Name string + }{} err := json.Unmarshal(v, &user) if err != nil { return result, err } - result.AssignableUsers = append(result.AssignableUsers, user) + result.AssignableUsers = append(result.AssignableUsers, NewAssignableUser(user.Id, user.Login, user.Name)) } } @@ -1183,26 +1187,86 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) return projects, nil } -type RepoAssignee struct { - ID string - Login string - Name string +type AssignableActor interface { + DisplayName() string + ID() string + Login() string + + sealedAssignableActor() +} + +// Always a user +type AssignableUser struct { + id string + login string + name string +} + +// NewAssignableUser is a test helper to create a new AssignableUser +// since the ID and Login are private fields +func NewAssignableUser(id, login, name string) AssignableUser { + return AssignableUser{ + id: id, + login: login, + name: name, + } } // DisplayName returns a formatted string that uses Login and Name to be displayed e.g. 'Login (Name)' or 'Login' -func (ra RepoAssignee) DisplayName() string { - if ra.Name != "" { - return fmt.Sprintf("%s (%s)", ra.Login, ra.Name) +func (u AssignableUser) DisplayName() string { + if u.name != "" { + return fmt.Sprintf("%s (%s)", u.login, u.name) } - return ra.Login + return u.login } +func (u AssignableUser) ID() string { + return u.id +} + +func (u AssignableUser) Login() string { + return u.login +} + +func (u AssignableUser) Name() string { + return u.name +} + +func (u AssignableUser) sealedAssignableActor() {} + +type AssignableBot struct { + id string + login string +} + +func (b AssignableBot) DisplayName() string { + return b.Login() +} + +func (b AssignableBot) ID() string { + return b.id +} + +func (b AssignableBot) Login() string { + return b.login +} + +func (b AssignableBot) Name() string { + return "" +} + +func (b AssignableBot) sealedAssignableActor() {} + // RepoAssignableUsers fetches all the assignable users for a repository -func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee, error) { +func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]AssignableUser, error) { type responseData struct { Repository struct { AssignableUsers struct { - Nodes []RepoAssignee + Nodes []struct { + ID string + Login string + Name string + } PageInfo struct { HasNextPage bool EndCursor string @@ -1217,7 +1281,7 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee, "endCursor": (*githubv4.String)(nil), } - var users []RepoAssignee + var users []AssignableUser for { var query responseData err := client.Query(repo.RepoHost(), "RepositoryAssignableUsers", &query, variables) @@ -1225,7 +1289,15 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee, return nil, err } - users = append(users, query.Repository.AssignableUsers.Nodes...) + for _, node := range query.Repository.AssignableUsers.Nodes { + user := AssignableUser{ + id: node.ID, + login: node.Login, + name: node.Name, + } + + users = append(users, user) + } if !query.Repository.AssignableUsers.PageInfo.HasNextPage { break } @@ -1237,26 +1309,26 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee, // RepoAssignableActors fetches all the assignable actors for a repository on // GitHub hosts that support Actor assignees. -func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]RepoAssignee, error) { - type repoBotAssignee struct { - ID string - Login string - TypeName string `graphql:"__typename"` - } - - type repoUserAssignee struct { +func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]AssignableActor, error) { + type assignableUser struct { ID string Login string Name string TypeName string `graphql:"__typename"` } + type assignableBot struct { + ID string + Login string + TypeName string `graphql:"__typename"` + } + type responseData struct { Repository struct { SuggestedActors struct { Nodes []struct { - User repoUserAssignee `graphql:"... on User"` - Bot repoBotAssignee `graphql:"... on Bot"` + User assignableUser `graphql:"... on User"` + Bot assignableBot `graphql:"... on Bot"` } PageInfo struct { HasNextPage bool @@ -1272,7 +1344,7 @@ func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]RepoAssignee "endCursor": (*githubv4.String)(nil), } - var actors []RepoAssignee + var actors []AssignableActor for { var query responseData err := client.Query(repo.RepoHost(), "RepositoryAssignableActors", &query, variables) @@ -1282,16 +1354,16 @@ func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]RepoAssignee for _, node := range query.Repository.SuggestedActors.Nodes { if node.User.TypeName == "User" { - actor := RepoAssignee{ - ID: node.User.ID, - Login: node.User.Login, - Name: node.User.Name, + actor := AssignableUser{ + id: node.User.ID, + login: node.User.Login, + name: node.User.Name, } actors = append(actors, actor) } else if node.Bot.TypeName == "Bot" { - actor := RepoAssignee{ - ID: node.Bot.ID, - Login: node.Bot.Login, + actor := AssignableBot{ + id: node.Bot.ID, + login: node.Bot.Login, } actors = append(actors, actor) } diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 01fc7a4c7..9040a0018 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -526,17 +526,17 @@ func Test_RepoMilestones(t *testing.T) { func TestDisplayName(t *testing.T) { tests := []struct { name string - assignee RepoAssignee + assignee AssignableUser want string }{ { name: "assignee with name", - assignee: RepoAssignee{"123", "octocat123", "Octavious Cath"}, + assignee: AssignableUser{"123", "octocat123", "Octavious Cath"}, want: "octocat123 (Octavious Cath)", }, { name: "assignee without name", - assignee: RepoAssignee{"123", "octocat123", ""}, + assignee: AssignableUser{"123", "octocat123", ""}, want: "octocat123", }, } diff --git a/pkg/cmd/pr/shared/completion.go b/pkg/cmd/pr/shared/completion.go index e07abc5a7..c1296be71 100644 --- a/pkg/cmd/pr/shared/completion.go +++ b/pkg/cmd/pr/shared/completion.go @@ -21,13 +21,13 @@ func RequestableReviewersForCompletion(httpClient *http.Client, repo ghrepo.Inte results := []string{} for _, user := range metadata.AssignableUsers { - if strings.EqualFold(user.Login, metadata.CurrentLogin) { + if strings.EqualFold(user.Login(), metadata.CurrentLogin) { continue } - if user.Name != "" { - results = append(results, fmt.Sprintf("%s\t%s", user.Login, user.Name)) + if user.Name() != "" { + results = append(results, fmt.Sprintf("%s\t%s", user.Login(), user.Name())) } else { - results = append(results, user.Login) + results = append(results, user.Login()) } } for _, team := range metadata.Teams { diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 238939efe..d51405acd 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -407,11 +407,11 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable) var users []string for _, u := range metadata.AssignableUsers { - users = append(users, u.Login) + users = append(users, u.Login()) } var actors []string for _, a := range metadata.AssignableActors { - actors = append(actors, a.Login) + actors = append(actors, a.Login()) } var teams []string for _, t := range metadata.Teams { diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index bf4476ca1..b6c927a2d 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -192,7 +192,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface var reviewers []string for _, u := range metadataResult.AssignableUsers { - if u.Login != metadataResult.CurrentLogin { + if u.Login() != metadataResult.CurrentLogin { reviewers = append(reviewers, u.DisplayName()) } } diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 6895b52ac..7097d0761 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -28,9 +28,9 @@ func TestMetadataSurvey_selectAll(t *testing.T) { fetcher := &metadataFetcher{ metadataResult: &api.RepoMetadataResult{ - AssignableUsers: []api.RepoAssignee{ - {Login: "hubot"}, - {Login: "monalisa"}, + AssignableUsers: []api.AssignableUser{ + api.NewAssignableUser("", "hubot", ""), + api.NewAssignableUser("", "monalisa", ""), }, Labels: []api.RepoLabel{ {Name: "help wanted"}, From 712eeabd4acbef9af03a297c38eec4e476cc993f Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 14 May 2025 08:55:55 -0600 Subject: [PATCH 132/249] doc(api): remove needless comment --- api/queries_repo.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 9e822767d..e6286bb0b 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -1202,8 +1202,6 @@ type AssignableUser struct { name string } -// NewAssignableUser is a test helper to create a new AssignableUser -// since the ID and Login are private fields func NewAssignableUser(id, login, name string) AssignableUser { return AssignableUser{ id: id, From 08cd1dc7db690477fcfc32aec3f8c171fc53bb80 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 14 May 2025 11:43:29 -0600 Subject: [PATCH 133/249] feat(issue edit): replacing actor assignee is done synchronously with updateIssue --- pkg/cmd/issue/edit/edit_test.go | 8 -------- pkg/cmd/pr/shared/editable_http.go | 29 +++++++++++++++++------------ 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index bf2a1c417..0df6d5e9f 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -574,14 +574,6 @@ func Test_editRun(t *testing.T) { { "errors": [ { "message": "test error" } ] }`, func(inputs map[string]interface{}) {}), ) - reg.Register( - httpmock.GraphQLMutationMatcher(`mutation IssueUpdate\b`, func(m map[string]interface{}) bool { - return m["id"] == "456" - }), - httpmock.GraphQLMutation(` - { "errors": [ { "message": "test error" } ] }`, - func(inputs map[string]interface{}) {}), - ) }, stdout: heredoc.Doc(` https://github.com/OWNER/REPO/issue/123 diff --git a/pkg/cmd/pr/shared/editable_http.go b/pkg/cmd/pr/shared/editable_http.go index 1475f978c..465bfda6c 100644 --- a/pkg/cmd/pr/shared/editable_http.go +++ b/pkg/cmd/pr/shared/editable_http.go @@ -58,23 +58,28 @@ func UpdateIssue(httpClient *http.Client, repo ghrepo.Interface, id string, isPR }) } - // updateIssue mutation does not support Actors so assignment needs to - // be in a separate request when our assignees are Actors. - if options.Assignees.Edited && options.Assignees.ActorAssignees { + if dirtyExcludingLabels(options) { wg.Go(func() error { - apiClient := api.NewClientFromHTTP(httpClient) - assigneeIds, err := options.AssigneeIds(apiClient, repo) + // updateIssue mutation does not support Actors so assignment needs to + // be in a separate request when our assignees are Actors. + if options.Assignees.Edited && options.Assignees.ActorAssignees { + apiClient := api.NewClientFromHTTP(httpClient) + assigneeIds, err := options.AssigneeIds(apiClient, repo) + if err != nil { + return err + } + + err = replaceActorAssigneesForEditable(apiClient, repo, id, assigneeIds) + if err != nil { + return err + } + } + err := replaceIssueFields(httpClient, repo, id, isPR, options) if err != nil { return err } - return replaceActorAssigneesForEditable(apiClient, repo, id, assigneeIds) - }) - } - - if dirtyExcludingLabels(options) { - wg.Go(func() error { - return replaceIssueFields(httpClient, repo, id, isPR, options) + return nil }) } From 5dc854c75e1735fb906640963dcaea462000f4c9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 14 May 2025 11:50:11 -0600 Subject: [PATCH 134/249] doc(issue edit): clarify synchronous handling of assignees --- pkg/cmd/pr/shared/editable_http.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/pr/shared/editable_http.go b/pkg/cmd/pr/shared/editable_http.go index 465bfda6c..cd183e565 100644 --- a/pkg/cmd/pr/shared/editable_http.go +++ b/pkg/cmd/pr/shared/editable_http.go @@ -62,6 +62,10 @@ func UpdateIssue(httpClient *http.Client, repo ghrepo.Interface, id string, isPR wg.Go(func() error { // updateIssue mutation does not support Actors so assignment needs to // be in a separate request when our assignees are Actors. + // Note: this is intentionally done synchronously with updating + // other issue fields to ensure consistency with how legacy + // user assignees are handled. + // https://github.com/cli/cli/pull/10960#discussion_r2086725348 if options.Assignees.Edited && options.Assignees.ActorAssignees { apiClient := api.NewClientFromHTTP(httpClient) assigneeIds, err := options.AssigneeIds(apiClient, repo) From 71f22d8843a8f5f862be19e7bd3f4cd53b82c118 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 13 May 2025 13:34:58 -0600 Subject: [PATCH 135/249] feat(pr edit): fetch assigned actors --- api/queries_pr.go | 1 + pkg/cmd/pr/edit/edit.go | 41 ++++++++++++++++++++++++++++++++++-- pkg/cmd/pr/edit/edit_test.go | 2 ++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 5b941bb42..525418a11 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -84,6 +84,7 @@ type PullRequest struct { } Assignees Assignees + AssignedActors AssignedActors Labels Labels ProjectCards ProjectCards ProjectItems ProjectItems diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 3c8d73ad3..23a75dd49 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -3,9 +3,11 @@ package edit import ( "fmt" "net/http" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" shared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -25,6 +27,8 @@ type EditOptions struct { Fetcher EditableOptionsFetcher EditorRetriever EditorRetriever Prompter shared.EditPrompter + Detector fd.Detector + BaseRepo func() (ghrepo.Interface, error) SelectorArg string Interactive bool @@ -69,6 +73,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { opts.Finder = shared.NewFinder(f) + opts.BaseRepo = f.BaseRepo if len(args) > 0 { opts.SelectorArg = args[0] @@ -192,8 +197,35 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman func editRun(opts *EditOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, - Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "assignees", "labels", "projectCards", "projectItems", "milestone"}, + Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "labels", "projectCards", "projectItems", "milestone"}, } + + if opts.Detector == nil { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + } + + issueFeatures, err := opts.Detector.IssueFeatures() + if err != nil { + return err + } + + if issueFeatures.ActorIsAssignable { + findOptions.Fields = append(findOptions.Fields, "assignedActors") + } else { + findOptions.Fields = append(findOptions.Fields, "assignees") + } + pr, repo, err := opts.Finder.Find(findOptions) if err != nil { return err @@ -205,7 +237,12 @@ func editRun(opts *EditOptions) error { editable.Body.Default = pr.Body editable.Base.Default = pr.BaseRefName editable.Reviewers.Default = pr.ReviewRequests.Logins() - editable.Assignees.Default = pr.Assignees.Logins() + if issueFeatures.ActorIsAssignable { + // editable.Assignees.ActorAssignees = true + editable.Assignees.Default = pr.AssignedActors.Logins() + } else { + editable.Assignees.Default = pr.Assignees.Logins() + } editable.Labels.Default = pr.Labels.Names() editable.Projects.Default = append(pr.ProjectCards.ProjectNames(), pr.ProjectItems.ProjectTitles()...) projectItems := map[string]string{} diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index f45984c76..64231fa70 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -507,9 +507,11 @@ func Test_editRun(t *testing.T) { tt.httpStubs(reg) httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } tt.input.IO = ios tt.input.HttpClient = httpClient + tt.input.BaseRepo = baseRepo err := editRun(tt.input) assert.NoError(t, err) From d4832ba0159f6a564d55a93918cd038d9d8acab2 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 14 May 2025 11:04:19 -0600 Subject: [PATCH 136/249] feat(pr edit): fetch assignable actors --- api/queries_repo.go | 42 +++++++++------------- pkg/cmd/pr/edit/edit.go | 2 +- pkg/cmd/pr/edit/edit_test.go | 70 +++++++++++++++++++++++++++++++----- 3 files changed, 78 insertions(+), 36 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index e6286bb0b..8f2646159 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -918,39 +918,30 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput var g errgroup.Group if input.Assignees || input.Reviewers { - if input.ActorAssignees { g.Go(func() error { actors, err := RepoAssignableActors(client, repo) if err != nil { - err = fmt.Errorf("error fetching assignees: %w", err) + return fmt.Errorf("error fetching assignees: %w", err) } result.AssignableActors = actors - return err - }) - // If reviewers are also requested, we still need to fetch the assignable users - // since commands use assignable users for reviewers too, - // but Actors are not supported for requesting review (need to confirm this). - // TODO KW: find out how to do this in the above query so we don't need to - // run two potentially expensive queries. When we fetch Actors, this - // should still return Users - Users are distinguishable from other Actors - // by having a name property. Maybe we can use the Name to filter out - // non-user Actors and populate the users list for reviewers based on - // that. - // Note: this only matters for `gh pr` flows, which currently does not - // request actor assignees, so we probably won't hit this until - // `gh pr` reqeuests actor assignees. - if input.Reviewers { - g.Go(func() error { - users, err := RepoAssignableUsers(client, repo) - if err != nil { - err = fmt.Errorf("error fetching assignees: %w", err) + // Processing the bots out from the actors + // because requesting a reviewer leverages + // result.AssignableUsers. + // Note: this prevents us from needing to make another + // request to fetch assignable users when the user has + // selected to modify both reviewers and assignees. + var users []AssignableUser + for _, a := range actors { + if _, ok := a.(AssignableUser); !ok { + continue } - result.AssignableUsers = users - return err - }) - } + users = append(users, a.(AssignableUser)) + } + result.AssignableUsers = users + return nil + }) } else { // Not using Actors, fetch legacy assignable users. g.Go(func() error { @@ -962,7 +953,6 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput return err }) } - } if input.Reviewers { diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 23a75dd49..d484f74ed 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -238,7 +238,7 @@ func editRun(opts *EditOptions) error { editable.Base.Default = pr.BaseRefName editable.Reviewers.Default = pr.ReviewRequests.Logins() if issueFeatures.ActorIsAssignable { - // editable.Assignees.ActorAssignees = true + editable.Assignees.ActorAssignees = true editable.Assignees.Default = pr.AssignedActors.Logins() } else { editable.Assignees.Default = pr.Assignees.Logins() diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 64231fa70..5c2e65eba 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" shared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -392,6 +393,7 @@ func Test_editRun(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { mockRepoMetadata(reg, false) mockPullRequestUpdate(reg) + mockPullRequestUpdateActorAssignees(reg) mockPullRequestReviewersUpdate(reg) mockPullRequestUpdateLabels(reg) mockProjectV2ItemUpdate(reg) @@ -448,6 +450,7 @@ func Test_editRun(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { mockRepoMetadata(reg, true) mockPullRequestUpdate(reg) + mockPullRequestUpdateActorAssignees(reg) mockPullRequestUpdateLabels(reg) mockProjectV2ItemUpdate(reg) }, @@ -468,6 +471,7 @@ func Test_editRun(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { mockRepoMetadata(reg, false) mockPullRequestUpdate(reg) + mockPullRequestUpdateActorAssignees(reg) mockPullRequestReviewersUpdate(reg) mockPullRequestUpdateLabels(reg) mockProjectV2ItemUpdate(reg) @@ -489,11 +493,50 @@ func Test_editRun(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { mockRepoMetadata(reg, true) mockPullRequestUpdate(reg) + mockPullRequestUpdateActorAssignees(reg) mockPullRequestUpdateLabels(reg) mockProjectV2ItemUpdate(reg) }, stdout: "https://github.com/OWNER/REPO/pull/123\n", }, + { + name: "Legacy assignee user are fetched and updated on unsupported GitHub Hosts", + input: &EditOptions{ + Detector: &fd.DisabledDetectorMock{}, + SelectorArg: "123", + Finder: shared.NewMockFinder("123", &api.PullRequest{ + URL: "https://github.com/OWNER/REPO/pull/123", + }, ghrepo.New("OWNER", "REPO")), + Interactive: false, + Editable: shared.Editable{ + Assignees: shared.EditableAssignees{ + EditableSlice: shared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Remove: []string{"octocat"}, + Edited: true, + }, + }, + }, + Fetcher: testFetcher{}, + }, + httpStubs: func(reg *httpmock.Registry) { + // Notice there is no call to mockReplaceActorsForAssignable() + // and no GraphQL call to RepositoryAssignableActors below. + reg.Register( + httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + httpmock.StringResponse(` + { "data": { "repository": { "assignableUsers": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID" }, + { "login": "MonaLisa", "id": "MONAID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + mockPullRequestUpdate(reg) + }, + stdout: "https://github.com/OWNER/REPO/pull/123\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -523,16 +566,16 @@ func Test_editRun(t *testing.T) { func mockRepoMetadata(reg *httpmock.Registry, skipReviewers bool) { reg.Register( - httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + httpmock.GraphQL(`query RepositoryAssignableActors\b`), httpmock.StringResponse(` - { "data": { "repository": { "assignableUsers": { - "nodes": [ - { "login": "hubot", "id": "HUBOTID" }, - { "login": "MonaLisa", "id": "MONAID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) + { "data": { "repository": { "suggestedActors": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID", "__typename": "Bot" }, + { "login": "MonaLisa", "id": "MONAID", "__typename": "User" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) reg.Register( httpmock.GraphQL(`query RepositoryLabelList\b`), httpmock.StringResponse(` @@ -635,6 +678,15 @@ func mockPullRequestUpdate(reg *httpmock.Registry) { httpmock.StringResponse(`{}`)) } +func mockPullRequestUpdateActorAssignees(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`), + httpmock.GraphQLMutation(` + { "data": { "replaceActorsForAssignable": { "__typename": "" } } }`, + func(inputs map[string]interface{}) {}), + ) +} + func mockPullRequestReviewersUpdate(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`mutation PullRequestUpdateRequestReviews\b`), From eace1e889ae41e7b17d8e3c3caf6922ad2c4633b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 14 May 2025 20:26:53 -0600 Subject: [PATCH 137/249] feat(editable): update assigned actors to use display names - Refactored AssignedActors to return display names instead of logins. - Updated related functions to ensure consistency in display names. - Enhanced comments for clarity on display name logic and actor types. --- api/queries_issue.go | 49 ++++++++++++++++++++++++++++------- api/queries_repo.go | 28 +++++++++++++++++--- api/query_builder.go | 21 ++++++++++++++- pkg/cmd/issue/edit/edit.go | 2 +- pkg/cmd/pr/edit/edit.go | 2 +- pkg/cmd/pr/shared/editable.go | 2 +- 6 files changed, 87 insertions(+), 17 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index 701b92039..bfdd223e5 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -93,19 +93,50 @@ func (a Assignees) Logins() []string { } type AssignedActors struct { - Edges []struct { - Node Actor - } + Nodes []Actor TotalCount int } -// TODO kw: Display names for actors with special display names. -func (a AssignedActors) Logins() []string { - logins := make([]string, len(a.Edges)) - for i, a := range a.Edges { - logins[i] = a.Node.Login +// DisplayNames returns a list of display names for the assigned actors. +func (a AssignedActors) DisplayNames() []string { + // These display names are used for populating the "default" assigned actors + // from the AssignedActors type. But, this is only one piece of the puzzle + // as later, other queries will fetch the full list of possible assignable + // actors from the repository, and the two lists will be reconciled. + // + // It's important that the display names are the same between the defaults + // (the values returned here) and the full list (the values returned by + // other repository queries). Any discrepancy would result in an + // "invalid default", which means an assigned actor will not be matched + // to an assignable actor and not presented as a "default" selection. + // Not being presented as a default would cause the actor to be potentially + // unassigned if the edits were submitted. + // + // To prevent this, we need shared logic to look up an actor's display name. + // However, our API types between assignedActors and the full list of + // assignableActors are different. So, as an attempt to maintain + // consistency we convert the assignedActors to the same types as the + // repository's assignableActors, treating the assignableActors DisplayName + // methods as the sources of truth. + // TODO KW: make this comment less of a wall of text if needed. + displayNames := make([]string, len(a.Nodes)) + for _, a := range a.Nodes { + if a.TypeName == "User" { + u := NewAssignableUser( + a.ID, + a.Login, + a.Name, + ) + displayNames = append(displayNames, u.DisplayName()) + } else if a.TypeName == "Bot" { + b := NewAssignableBot( + a.ID, + a.Login, + ) + displayNames = append(displayNames, b.DisplayName()) + } } - return logins + return displayNames } type Labels struct { diff --git a/api/queries_repo.go b/api/queries_repo.go index 8f2646159..dd9027ad4 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -146,11 +146,16 @@ type GitHubUser struct { Name string `json:"name"` } -// Actor is a superset of User and Bot -// At the time of writing, it does not support Name. +// Actor is a superset of User and Bot, among others. +// At the time of writing, some of these fields +// are not directly supported by the Actor type and +// instead are only available on the User or Bot types +// directly. type Actor struct { - ID string `json:"id"` - Login string `json:"login"` + ID string `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + TypeName string `json:"__typename"` } // BranchRef is the branch name in a GitHub repository @@ -710,6 +715,11 @@ func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { found = true break } + if strings.EqualFold(assigneeLogin, a.DisplayName()) { + ids = append(ids, a.ID()) + found = true + break + } } if !found { @@ -1227,7 +1237,17 @@ type AssignableBot struct { login string } +func NewAssignableBot(id, login string) AssignableBot { + return AssignableBot{ + id: id, + login: login, + } +} + func (b AssignableBot) DisplayName() string { + if b.login == "copilot-swe-agent" { + return "Copilot (AI)" + } return b.Login() } diff --git a/api/query_builder.go b/api/query_builder.go index 0ef44a347..a2432673b 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -20,6 +20,25 @@ func shortenQuery(q string) string { return strings.Map(squeeze, q) } +var assignedActors = shortenQuery(` + assignedActors(first: 10) { + nodes { + ...on User { + id, + login, + name, + __typename + } + ...on Bot { + id, + login, + __typename + } + }, + totalCount + } +`) + var issueComments = shortenQuery(` comments(first: 100) { nodes { @@ -367,7 +386,7 @@ func IssueGraphQL(fields []string) string { case "assignees": q = append(q, `assignees(first:100){nodes{id,login,name},totalCount}`) case "assignedActors": - q = append(q, `assignedActors(first: 10){edges{node{...on Actor{login}}},totalCount}`) + q = append(q, assignedActors) case "labels": q = append(q, `labels(first:100){nodes{id,name,description,color},totalCount}`) case "projectCards": diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 5cb789543..f3c4e46ad 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -267,7 +267,7 @@ func editRun(opts *EditOptions) error { // We use Actors as the default assignees if Actors are assignable // on this GitHub host. if editable.Assignees.ActorAssignees { - editable.Assignees.Default = issue.AssignedActors.Logins() + editable.Assignees.Default = issue.AssignedActors.DisplayNames() } else { editable.Assignees.Default = issue.Assignees.Logins() } diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index d484f74ed..3002c8dbe 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -239,7 +239,7 @@ func editRun(opts *EditOptions) error { editable.Reviewers.Default = pr.ReviewRequests.Logins() if issueFeatures.ActorIsAssignable { editable.Assignees.ActorAssignees = true - editable.Assignees.Default = pr.AssignedActors.Logins() + editable.Assignees.Default = pr.AssignedActors.DisplayNames() } else { editable.Assignees.Default = pr.Assignees.Logins() } diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index d51405acd..cc11812ae 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -411,7 +411,7 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable) } var actors []string for _, a := range metadata.AssignableActors { - actors = append(actors, a.Login()) + actors = append(actors, a.DisplayName()) } var teams []string for _, t := range metadata.Teams { From da40e087460c0d01f741fba8200b1c5d825b2bee Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 08:33:30 -0600 Subject: [PATCH 138/249] doc(api): code comment typo Co-authored-by: Andy Feller --- api/queries_repo.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index e6286bb0b..aa180a015 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -940,7 +940,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput // that. // Note: this only matters for `gh pr` flows, which currently does not // request actor assignees, so we probably won't hit this until - // `gh pr` reqeuests actor assignees. + // `gh pr` requests actor assignees. if input.Reviewers { g.Go(func() error { users, err := RepoAssignableUsers(client, repo) From ea85b92b35dccaae5a4b359a33c74d5a83c21707 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 09:09:36 -0600 Subject: [PATCH 139/249] refactor(api): remove needless parenthesis Co-authored-by: Andy Feller --- api/queries_repo.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index aa180a015..978e844c4 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -696,7 +696,7 @@ func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { for _, assigneeLogin := range names { found := false for _, u := range m.AssignableUsers { - if strings.EqualFold(assigneeLogin, (u.Login())) { + if strings.EqualFold(assigneeLogin, u.Login()) { ids = append(ids, u.ID()) found = true break From bcd47f1fb178c60ad7e87f75d3cfbe00f00545d1 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 09:13:46 -0600 Subject: [PATCH 140/249] fix(api): correct var name capitalization --- pkg/cmd/pr/shared/editable_http.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/editable_http.go b/pkg/cmd/pr/shared/editable_http.go index cd183e565..8cd51c349 100644 --- a/pkg/cmd/pr/shared/editable_http.go +++ b/pkg/cmd/pr/shared/editable_http.go @@ -103,7 +103,7 @@ func replaceActorAssigneesForEditable(apiClient *api.Client, repo ghrepo.Interfa var mutation struct { ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` + TypeName string `graphql:"__typename"` } `graphql:"replaceActorsForAssignable(input: $input)"` } From 0db7ae872f71ec7926dc96c34430a60aa75fc5bf Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 09:09:36 -0600 Subject: [PATCH 141/249] refactor(api): remove needless parenthesis Co-authored-by: Andy Feller --- api/queries_repo.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index e6286bb0b..732d89c8c 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -696,7 +696,7 @@ func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { for _, assigneeLogin := range names { found := false for _, u := range m.AssignableUsers { - if strings.EqualFold(assigneeLogin, (u.Login())) { + if strings.EqualFold(assigneeLogin, u.Login()) { ids = append(ids, u.ID()) found = true break From 6162a7c524d4507412e312e275ebff2d0a59da33 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 09:13:46 -0600 Subject: [PATCH 142/249] fix(api): correct var name capitalization --- pkg/cmd/pr/shared/editable_http.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/editable_http.go b/pkg/cmd/pr/shared/editable_http.go index cd183e565..8cd51c349 100644 --- a/pkg/cmd/pr/shared/editable_http.go +++ b/pkg/cmd/pr/shared/editable_http.go @@ -103,7 +103,7 @@ func replaceActorAssigneesForEditable(apiClient *api.Client, repo ghrepo.Interfa var mutation struct { ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` + TypeName string `graphql:"__typename"` } `graphql:"replaceActorsForAssignable(input: $input)"` } From f55906810645e4a22eeb227d4b189ae9a9a0acaa Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 13 May 2025 13:34:58 -0600 Subject: [PATCH 143/249] feat(pr edit): fetch assigned actors --- api/queries_pr.go | 1 + pkg/cmd/pr/edit/edit.go | 41 ++++++++++++++++++++++++++++++++++-- pkg/cmd/pr/edit/edit_test.go | 2 ++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 5b941bb42..525418a11 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -84,6 +84,7 @@ type PullRequest struct { } Assignees Assignees + AssignedActors AssignedActors Labels Labels ProjectCards ProjectCards ProjectItems ProjectItems diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 3c8d73ad3..23a75dd49 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -3,9 +3,11 @@ package edit import ( "fmt" "net/http" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" shared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -25,6 +27,8 @@ type EditOptions struct { Fetcher EditableOptionsFetcher EditorRetriever EditorRetriever Prompter shared.EditPrompter + Detector fd.Detector + BaseRepo func() (ghrepo.Interface, error) SelectorArg string Interactive bool @@ -69,6 +73,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { opts.Finder = shared.NewFinder(f) + opts.BaseRepo = f.BaseRepo if len(args) > 0 { opts.SelectorArg = args[0] @@ -192,8 +197,35 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman func editRun(opts *EditOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, - Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "assignees", "labels", "projectCards", "projectItems", "milestone"}, + Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "labels", "projectCards", "projectItems", "milestone"}, } + + if opts.Detector == nil { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + } + + issueFeatures, err := opts.Detector.IssueFeatures() + if err != nil { + return err + } + + if issueFeatures.ActorIsAssignable { + findOptions.Fields = append(findOptions.Fields, "assignedActors") + } else { + findOptions.Fields = append(findOptions.Fields, "assignees") + } + pr, repo, err := opts.Finder.Find(findOptions) if err != nil { return err @@ -205,7 +237,12 @@ func editRun(opts *EditOptions) error { editable.Body.Default = pr.Body editable.Base.Default = pr.BaseRefName editable.Reviewers.Default = pr.ReviewRequests.Logins() - editable.Assignees.Default = pr.Assignees.Logins() + if issueFeatures.ActorIsAssignable { + // editable.Assignees.ActorAssignees = true + editable.Assignees.Default = pr.AssignedActors.Logins() + } else { + editable.Assignees.Default = pr.Assignees.Logins() + } editable.Labels.Default = pr.Labels.Names() editable.Projects.Default = append(pr.ProjectCards.ProjectNames(), pr.ProjectItems.ProjectTitles()...) projectItems := map[string]string{} diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index f45984c76..64231fa70 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -507,9 +507,11 @@ func Test_editRun(t *testing.T) { tt.httpStubs(reg) httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } tt.input.IO = ios tt.input.HttpClient = httpClient + tt.input.BaseRepo = baseRepo err := editRun(tt.input) assert.NoError(t, err) From d72018d3120dde1fb783afa3f612cd9255e1eb32 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 14 May 2025 11:04:19 -0600 Subject: [PATCH 144/249] feat(pr edit): fetch assignable actors --- api/queries_repo.go | 42 +++++++++------------- pkg/cmd/pr/edit/edit.go | 2 +- pkg/cmd/pr/edit/edit_test.go | 70 +++++++++++++++++++++++++++++++----- 3 files changed, 78 insertions(+), 36 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 732d89c8c..437261c21 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -918,39 +918,30 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput var g errgroup.Group if input.Assignees || input.Reviewers { - if input.ActorAssignees { g.Go(func() error { actors, err := RepoAssignableActors(client, repo) if err != nil { - err = fmt.Errorf("error fetching assignees: %w", err) + return fmt.Errorf("error fetching assignees: %w", err) } result.AssignableActors = actors - return err - }) - // If reviewers are also requested, we still need to fetch the assignable users - // since commands use assignable users for reviewers too, - // but Actors are not supported for requesting review (need to confirm this). - // TODO KW: find out how to do this in the above query so we don't need to - // run two potentially expensive queries. When we fetch Actors, this - // should still return Users - Users are distinguishable from other Actors - // by having a name property. Maybe we can use the Name to filter out - // non-user Actors and populate the users list for reviewers based on - // that. - // Note: this only matters for `gh pr` flows, which currently does not - // request actor assignees, so we probably won't hit this until - // `gh pr` reqeuests actor assignees. - if input.Reviewers { - g.Go(func() error { - users, err := RepoAssignableUsers(client, repo) - if err != nil { - err = fmt.Errorf("error fetching assignees: %w", err) + // Processing the bots out from the actors + // because requesting a reviewer leverages + // result.AssignableUsers. + // Note: this prevents us from needing to make another + // request to fetch assignable users when the user has + // selected to modify both reviewers and assignees. + var users []AssignableUser + for _, a := range actors { + if _, ok := a.(AssignableUser); !ok { + continue } - result.AssignableUsers = users - return err - }) - } + users = append(users, a.(AssignableUser)) + } + result.AssignableUsers = users + return nil + }) } else { // Not using Actors, fetch legacy assignable users. g.Go(func() error { @@ -962,7 +953,6 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput return err }) } - } if input.Reviewers { diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 23a75dd49..d484f74ed 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -238,7 +238,7 @@ func editRun(opts *EditOptions) error { editable.Base.Default = pr.BaseRefName editable.Reviewers.Default = pr.ReviewRequests.Logins() if issueFeatures.ActorIsAssignable { - // editable.Assignees.ActorAssignees = true + editable.Assignees.ActorAssignees = true editable.Assignees.Default = pr.AssignedActors.Logins() } else { editable.Assignees.Default = pr.Assignees.Logins() diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 64231fa70..5c2e65eba 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/cli/cli/v2/api" + fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" shared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -392,6 +393,7 @@ func Test_editRun(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { mockRepoMetadata(reg, false) mockPullRequestUpdate(reg) + mockPullRequestUpdateActorAssignees(reg) mockPullRequestReviewersUpdate(reg) mockPullRequestUpdateLabels(reg) mockProjectV2ItemUpdate(reg) @@ -448,6 +450,7 @@ func Test_editRun(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { mockRepoMetadata(reg, true) mockPullRequestUpdate(reg) + mockPullRequestUpdateActorAssignees(reg) mockPullRequestUpdateLabels(reg) mockProjectV2ItemUpdate(reg) }, @@ -468,6 +471,7 @@ func Test_editRun(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { mockRepoMetadata(reg, false) mockPullRequestUpdate(reg) + mockPullRequestUpdateActorAssignees(reg) mockPullRequestReviewersUpdate(reg) mockPullRequestUpdateLabels(reg) mockProjectV2ItemUpdate(reg) @@ -489,11 +493,50 @@ func Test_editRun(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { mockRepoMetadata(reg, true) mockPullRequestUpdate(reg) + mockPullRequestUpdateActorAssignees(reg) mockPullRequestUpdateLabels(reg) mockProjectV2ItemUpdate(reg) }, stdout: "https://github.com/OWNER/REPO/pull/123\n", }, + { + name: "Legacy assignee user are fetched and updated on unsupported GitHub Hosts", + input: &EditOptions{ + Detector: &fd.DisabledDetectorMock{}, + SelectorArg: "123", + Finder: shared.NewMockFinder("123", &api.PullRequest{ + URL: "https://github.com/OWNER/REPO/pull/123", + }, ghrepo.New("OWNER", "REPO")), + Interactive: false, + Editable: shared.Editable{ + Assignees: shared.EditableAssignees{ + EditableSlice: shared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Remove: []string{"octocat"}, + Edited: true, + }, + }, + }, + Fetcher: testFetcher{}, + }, + httpStubs: func(reg *httpmock.Registry) { + // Notice there is no call to mockReplaceActorsForAssignable() + // and no GraphQL call to RepositoryAssignableActors below. + reg.Register( + httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + httpmock.StringResponse(` + { "data": { "repository": { "assignableUsers": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID" }, + { "login": "MonaLisa", "id": "MONAID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + mockPullRequestUpdate(reg) + }, + stdout: "https://github.com/OWNER/REPO/pull/123\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -523,16 +566,16 @@ func Test_editRun(t *testing.T) { func mockRepoMetadata(reg *httpmock.Registry, skipReviewers bool) { reg.Register( - httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + httpmock.GraphQL(`query RepositoryAssignableActors\b`), httpmock.StringResponse(` - { "data": { "repository": { "assignableUsers": { - "nodes": [ - { "login": "hubot", "id": "HUBOTID" }, - { "login": "MonaLisa", "id": "MONAID" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) + { "data": { "repository": { "suggestedActors": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID", "__typename": "Bot" }, + { "login": "MonaLisa", "id": "MONAID", "__typename": "User" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) reg.Register( httpmock.GraphQL(`query RepositoryLabelList\b`), httpmock.StringResponse(` @@ -635,6 +678,15 @@ func mockPullRequestUpdate(reg *httpmock.Registry) { httpmock.StringResponse(`{}`)) } +func mockPullRequestUpdateActorAssignees(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`), + httpmock.GraphQLMutation(` + { "data": { "replaceActorsForAssignable": { "__typename": "" } } }`, + func(inputs map[string]interface{}) {}), + ) +} + func mockPullRequestReviewersUpdate(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`mutation PullRequestUpdateRequestReviews\b`), From 54f48cfe46e231a7f1d45bdab0706ed999690f60 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 09:47:30 -0600 Subject: [PATCH 145/249] fix(pr edit): remove merge conflict artifact, extra detector --- pkg/cmd/pr/edit/edit.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 75b8c4e09..8d2ff8ddb 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -21,9 +21,6 @@ import ( type EditOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams - // TODO projectsV1Deprecation - // Remove this detector since it is only used for test validation. - Detector fd.Detector Finder shared.PRFinder Surveyor Surveyor From 9a5ea87d75bfb8ea2bb0c4d81f9237301cab2e3d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 09:56:35 -0600 Subject: [PATCH 146/249] fix(issues): fix non-interactive assignee matching to logins&IDs --- api/queries_issue.go | 10 +++++++++- pkg/cmd/issue/edit/edit.go | 1 + pkg/cmd/pr/shared/editable.go | 7 ++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index bfdd223e5..24e0b4f4c 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -97,6 +97,14 @@ type AssignedActors struct { TotalCount int } +func (a AssignedActors) Logins() []string { + logins := make([]string, len(a.Nodes)) + for i, a := range a.Nodes { + logins[i] = a.Login + } + return logins +} + // DisplayNames returns a list of display names for the assigned actors. func (a AssignedActors) DisplayNames() []string { // These display names are used for populating the "default" assigned actors @@ -119,7 +127,7 @@ func (a AssignedActors) DisplayNames() []string { // repository's assignableActors, treating the assignableActors DisplayName // methods as the sources of truth. // TODO KW: make this comment less of a wall of text if needed. - displayNames := make([]string, len(a.Nodes)) + var displayNames []string for _, a := range a.Nodes { if a.TypeName == "User" { u := NewAssignableUser( diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index f3c4e46ad..8fc8a2e41 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -268,6 +268,7 @@ func editRun(opts *EditOptions) error { // on this GitHub host. if editable.Assignees.ActorAssignees { editable.Assignees.Default = issue.AssignedActors.DisplayNames() + editable.Assignees.DefaultLogins = issue.AssignedActors.Logins() } else { editable.Assignees.Default = issue.Assignees.Logins() } diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index cc11812ae..cfe022c78 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -43,6 +43,7 @@ type EditableSlice struct { type EditableAssignees struct { EditableSlice ActorAssignees bool + DefaultLogins []string } // ProjectsV2 mutations require a mapping of an item ID to a project ID. @@ -115,7 +116,11 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str if len(e.Assignees.Add) != 0 || len(e.Assignees.Remove) != 0 { meReplacer := NewMeReplacer(client, repo.RepoHost()) s := set.NewStringSet() - s.AddValues(e.Assignees.Default) + if e.Assignees.ActorAssignees { + s.AddValues(e.Assignees.DefaultLogins) + } else { + s.AddValues(e.Assignees.Default) + } add, err := meReplacer.ReplaceSlice(e.Assignees.Add) if err != nil { return nil, err From 52b6ebff9cf1f4c9a5570cc0d105447cfc7c938c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 10:36:57 -0600 Subject: [PATCH 147/249] doc(pr edit): Add comments describing the use of DefaultLogins --- pkg/cmd/pr/shared/editable.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index cfe022c78..77bab8440 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -43,7 +43,7 @@ type EditableSlice struct { type EditableAssignees struct { EditableSlice ActorAssignees bool - DefaultLogins []string + DefaultLogins []string // For disambiguating actors from display names } // ProjectsV2 mutations require a mapping of an item ID to a project ID. @@ -113,9 +113,20 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str if !e.Assignees.Edited { return nil, nil } + + // If assignees came in from command line flags, we need to + // curate the final list of assignees from the default list. if len(e.Assignees.Add) != 0 || len(e.Assignees.Remove) != 0 { meReplacer := NewMeReplacer(client, repo.RepoHost()) s := set.NewStringSet() + // This check below is required because in a non-interactive flow, + // the user gives us a login and not the DisplayName, and when + // we have actor assignees e.Assignees.Default will contain + // DisplayNames and not logins (this is to accommodate special actor + // display names in the interactive flow). + // So, we need to add the default logins here instead of the DisplayNames. + // Otherwise, the value the user provided won't be found in the + // set to be added or removed, causing unexpected behavior. if e.Assignees.ActorAssignees { s.AddValues(e.Assignees.DefaultLogins) } else { From 375c6cd28f7ded11fe8b53974735ec10404ea865 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 12:50:03 -0600 Subject: [PATCH 148/249] feat(issue/pr edit): support @copilot in assignee flags - Introduced CopilotReplacer to handle `@copilot` mentions in assignee lists. --- pkg/cmd/pr/shared/editable.go | 40 +++++++++++++++++++----- pkg/cmd/pr/shared/params.go | 27 ++++++++++++++++ pkg/cmd/pr/shared/params_test.go | 53 ++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 77bab8440..1bbcd3113 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -118,7 +118,28 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str // curate the final list of assignees from the default list. if len(e.Assignees.Add) != 0 || len(e.Assignees.Remove) != 0 { meReplacer := NewMeReplacer(client, repo.RepoHost()) - s := set.NewStringSet() + copilotReplacer := NewCopilotReplacer() + + // A closure to replace special assignee names with the actual logins. + replaceSpecialAssigneeNames := func(value []string) ([]string, error) { + replaced, err := meReplacer.ReplaceSlice(value) + if err != nil { + return nil, err + } + + // Only suppported for actor assignees. + if e.Assignees.ActorAssignees { + replaced, err = copilotReplacer.ReplaceSlice(replaced) + if err != nil { + return nil, err + } + } + + return replaced, nil + } + + assigneeSet := set.NewStringSet() + // This check below is required because in a non-interactive flow, // the user gives us a login and not the DisplayName, and when // we have actor assignees e.Assignees.Default will contain @@ -128,21 +149,24 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str // Otherwise, the value the user provided won't be found in the // set to be added or removed, causing unexpected behavior. if e.Assignees.ActorAssignees { - s.AddValues(e.Assignees.DefaultLogins) + assigneeSet.AddValues(e.Assignees.DefaultLogins) } else { - s.AddValues(e.Assignees.Default) + assigneeSet.AddValues(e.Assignees.Default) } - add, err := meReplacer.ReplaceSlice(e.Assignees.Add) + + add, err := replaceSpecialAssigneeNames(e.Assignees.Add) if err != nil { return nil, err } - s.AddValues(add) - remove, err := meReplacer.ReplaceSlice(e.Assignees.Remove) + assigneeSet.AddValues(add) + + remove, err := replaceSpecialAssigneeNames(e.Assignees.Remove) if err != nil { return nil, err } - s.RemoveValues(remove) - e.Assignees.Value = s.ToSlice() + assigneeSet.RemoveValues(remove) + + e.Assignees.Value = assigneeSet.ToSlice() } a, err := e.Metadata.MembersToIDs(e.Assignees.Value) return &a, err diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 08968939d..7ea364707 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -312,3 +312,30 @@ func (r *MeReplacer) ReplaceSlice(handles []string) ([]string, error) { } return res, nil } + +// CopilotReplacer resolves usages of `@copilot` to Copilot's login. +type CopilotReplacer struct{} + +func NewCopilotReplacer() *CopilotReplacer { + return &CopilotReplacer{} +} + +func (r *CopilotReplacer) replace(handle string) (string, error) { + if strings.EqualFold(handle, "@copilot") { + return "copilot-swe-agent", nil + } + return handle, nil +} + +// Replace replaces usages of `@copilot` in a slice with Copilot's login. +func (r *CopilotReplacer) ReplaceSlice(handles []string) ([]string, error) { + res := make([]string, len(handles)) + for i, h := range handles { + var err error + res[i], err = r.replace(h) + if err != nil { + return nil, err + } + } + return res, nil +} diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go index 15f00ca4f..b1e3d32d6 100644 --- a/pkg/cmd/pr/shared/params_test.go +++ b/pkg/cmd/pr/shared/params_test.go @@ -187,6 +187,59 @@ func TestMeReplacer_Replace(t *testing.T) { } } +func TestCopilotReplacer_ReplaceSlice(t *testing.T) { + type args struct { + handles []string + } + tests := []struct { + name string + args args + want []string + wantErr bool + }{ + { + name: "replaces @copilot with copilot-swe-agent", + args: args{ + handles: []string{"monalisa", "@copilot", "hubot"}, + }, + want: []string{"monalisa", "copilot-swe-agent", "hubot"}, + wantErr: false, + }, + { + name: "handles no @copilot mentions", + args: args{ + handles: []string{"monalisa", "user", "hubot"}, + }, + want: []string{"monalisa", "user", "hubot"}, + wantErr: false, + }, + { + name: "replaces multiple @copilot mentions", + args: args{ + handles: []string{"@copilot", "user", "@copilot"}, + }, + want: []string{"copilot-swe-agent", "user", "copilot-swe-agent"}, + wantErr: false, + }, + { + name: "handles @copilot case-insensitively", + args: args{ + handles: []string{"@Copilot", "user", "@CoPiLoT"}, + }, + want: []string{"copilot-swe-agent", "user", "copilot-swe-agent"}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewCopilotReplacer() + got, err := r.ReplaceSlice(tt.args.handles) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + func Test_QueryHasStateClause(t *testing.T) { tests := []struct { searchQuery string From f6bb1ca75653975d42a7d43c7c7ee24784a79d8a Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 13:40:23 -0600 Subject: [PATCH 149/249] refactor(pr edit): move httpclient initialization --- pkg/cmd/pr/edit/edit.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 8d2ff8ddb..ff293a511 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -201,12 +201,12 @@ func editRun(opts *EditOptions) error { Detector: opts.Detector, } - if opts.Detector == nil { - httpClient, err := opts.HttpClient() - if err != nil { - return err - } + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + if opts.Detector == nil { baseRepo, err := opts.BaseRepo() if err != nil { return err @@ -262,10 +262,6 @@ func editRun(opts *EditOptions) error { } } - httpClient, err := opts.HttpClient() - if err != nil { - return err - } apiClient := api.NewClientFromHTTP(httpClient) opts.IO.StartProgressIndicator() From ec7c8ed844ec1532b6026af5d0b57bb0d238ed58 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 13:41:38 -0600 Subject: [PATCH 150/249] test(pr edit): fix typo in test name Co-authored-by: Andy Feller --- pkg/cmd/pr/edit/edit_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 0f15d6bd1..76fb40f88 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -500,7 +500,7 @@ func Test_editRun(t *testing.T) { stdout: "https://github.com/OWNER/REPO/pull/123\n", }, { - name: "Legacy assignee user are fetched and updated on unsupported GitHub Hosts", + name: "Legacy assignee users are fetched and updated on unsupported GitHub Hosts", input: &EditOptions{ Detector: &fd.DisabledDetectorMock{}, SelectorArg: "123", From 981da867018817f7ea08dae2259e022f2f231fe0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 13:47:51 -0600 Subject: [PATCH 151/249] doc(pr edit): condense comment for reviewer/user filtering --- api/queries_repo.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 437261c21..1bd814746 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -926,12 +926,8 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput } result.AssignableActors = actors - // Processing the bots out from the actors - // because requesting a reviewer leverages - // result.AssignableUsers. - // Note: this prevents us from needing to make another - // request to fetch assignable users when the user has - // selected to modify both reviewers and assignees. + // Filter actors for users to use for pull request reviewers, + // skip retrieving the same info through RepoAssignableUsers(). var users []AssignableUser for _, a := range actors { if _, ok := a.(AssignableUser); !ok { From 8bd77c0a956680b16cfc0610e61f803bbe05b7ca Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 14:01:10 -0600 Subject: [PATCH 152/249] fix(pr edit): clarify error messages for assignee actors and users --- api/queries_repo.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 1bd814746..fd5ae4679 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -922,7 +922,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput g.Go(func() error { actors, err := RepoAssignableActors(client, repo) if err != nil { - return fmt.Errorf("error fetching assignees: %w", err) + return fmt.Errorf("error fetching assignable actors: %w", err) } result.AssignableActors = actors @@ -943,7 +943,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput g.Go(func() error { users, err := RepoAssignableUsers(client, repo) if err != nil { - err = fmt.Errorf("error fetching assignees: %w", err) + err = fmt.Errorf("error fetching assignable users: %w", err) } result.AssignableUsers = users return err From 3a6e42f73f373cdf086d3b12f64634d2cba60b32 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Thu, 15 May 2025 17:21:13 -0400 Subject: [PATCH 153/249] init --- pkg/cmd/attestation/artifact/artifact.go | 8 + pkg/cmd/release/release.go | 2 + pkg/cmd/release/shared/fetch.go | 35 +++ pkg/cmd/release/verify/attestation.go | 58 +++++ pkg/cmd/release/verify/options.go | 104 +++++++++ pkg/cmd/release/verify/policy.go | 168 +++++++++++++ pkg/cmd/release/verify/verify.go | 285 +++++++++++++++++++++++ 7 files changed, 660 insertions(+) create mode 100644 pkg/cmd/release/verify/attestation.go create mode 100644 pkg/cmd/release/verify/options.go create mode 100644 pkg/cmd/release/verify/policy.go create mode 100644 pkg/cmd/release/verify/verify.go diff --git a/pkg/cmd/attestation/artifact/artifact.go b/pkg/cmd/attestation/artifact/artifact.go index 131785166..53f8d8aad 100644 --- a/pkg/cmd/attestation/artifact/artifact.go +++ b/pkg/cmd/attestation/artifact/artifact.go @@ -54,6 +54,14 @@ func normalizeReference(reference string, pathSeparator rune) (normalized string return filepath.Clean(reference), fileArtifactType, nil } +func NewDigestedArtifactForRelease(URL string, digest string, digestAlg string) (artifact *DigestedArtifact) { + return &DigestedArtifact{ + URL: URL, + digest: digest, + digestAlg: digestAlg, + } +} + func NewDigestedArtifact(client oci.Client, reference, digestAlg string) (artifact *DigestedArtifact, err error) { normalized, artifactType, err := normalizeReference(reference, os.PathSeparator) if err != nil { diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index 6805b09eb..3e40b03e7 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -8,6 +8,7 @@ import ( cmdUpdate "github.com/cli/cli/v2/pkg/cmd/release/edit" cmdList "github.com/cli/cli/v2/pkg/cmd/release/list" cmdUpload "github.com/cli/cli/v2/pkg/cmd/release/upload" + cmdVerify "github.com/cli/cli/v2/pkg/cmd/release/verify" cmdView "github.com/cli/cli/v2/pkg/cmd/release/view" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -34,6 +35,7 @@ func NewCmdRelease(f *cmdutil.Factory) *cobra.Command { cmdDownload.NewCmdDownload(f, nil), cmdDelete.NewCmdDelete(f, nil), cmdDeleteAsset.NewCmdDeleteAsset(f, nil), + cmdVerify.NewCmdVerify(f, nil), ) return cmd diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 8db7e502a..5fea30b7c 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -131,6 +131,41 @@ type fetchResult struct { error error } +func FetchRefSHA(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (string, error) { + path := fmt.Sprintf("repos/%s/%s/git/refs/tags/%s", repo.RepoOwner(), repo.RepoName(), tagName) + req, err := http.NewRequestWithContext(ctx, "GET", ghinstance.RESTPrefix(repo.RepoHost())+path, nil) + if err != nil { + return "", err + } + + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + _, _ = io.Copy(io.Discard, resp.Body) + // ErrRefNotFound + return "", ErrReleaseNotFound + } + + if resp.StatusCode > 299 { + return "", api.HandleHTTPError(resp) + } + + var ref struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` + } + if err := json.NewDecoder(resp.Body).Decode(&ref); err != nil { + return "", err + } + + return ref.Object.SHA, nil +} + // FetchRelease finds a published repository release by its tagName, or a draft release by its pending tag name. func FetchRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (*Release, error) { cc, cancel := context.WithCancel(ctx) diff --git a/pkg/cmd/release/verify/attestation.go b/pkg/cmd/release/verify/attestation.go new file mode 100644 index 000000000..9aa7461ae --- /dev/null +++ b/pkg/cmd/release/verify/attestation.go @@ -0,0 +1,58 @@ +package verify + +import ( + "errors" + "fmt" + + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" +) + +func getAttestations(o *Options, sha string) ([]*api.Attestation, string, error) { + if o.APIClient == nil { + errMsg := "✗ No APIClient provided" + return nil, errMsg, errors.New(errMsg) + } + + params := api.FetchParams{ + Digest: sha, + Limit: o.Limit, + Owner: o.Owner, + PredicateType: o.PredicateType, + Repo: o.Repo, + } + + attestations, err := o.APIClient.GetByDigest(params) + if err != nil { + msg := "✗ Loading attestations from GitHub API failed" + return nil, msg, err + } + pluralAttestation := text.Pluralize(len(attestations), "attestation") + msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation) + return attestations, msg, nil +} + +func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { + sgPolicy, err := buildSigstoreVerifyPolicy(ec, art) + if err != nil { + logMsg := "✗ Failed to build Sigstore verification policy" + return nil, logMsg, err + } + + sigstoreVerified, err := sgVerifier.Verify(att, sgPolicy) + if err != nil { + logMsg := "✗ Sigstore verification failed" + return nil, logMsg, err + } + + // Verify extensions + certExtVerified, err := verification.VerifyCertExtensions(sigstoreVerified, ec) + if err != nil { + logMsg := "✗ Policy verification failed" + return nil, logMsg, err + } + + return certExtVerified, "", nil +} diff --git a/pkg/cmd/release/verify/options.go b/pkg/cmd/release/verify/options.go new file mode 100644 index 000000000..e47c4f4a8 --- /dev/null +++ b/pkg/cmd/release/verify/options.go @@ -0,0 +1,104 @@ +package verify + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" + "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmdutil" +) + +// Options captures the options for the verify command +type Options struct { + ArtifactPath string + BundlePath string + UseBundleFromRegistry bool + Config func() (gh.Config, error) + TrustedRoot string + DenySelfHostedRunner bool + DigestAlgorithm string + Limit int + NoPublicGood bool + OIDCIssuer string + Owner string + PredicateType string + Repo string + SAN string + SANRegex string + SignerDigest string + SignerRepo string + SignerWorkflow string + SourceDigest string + SourceRef string + APIClient api.Client + Logger *io.Handler + OCIClient oci.Client + SigstoreVerifier verification.SigstoreVerifier + exporter cmdutil.Exporter + Hostname string + // Tenant is only set when tenancy is used + Tenant string +} + +// Clean cleans the file path option values +func (opts *Options) Clean() { + if opts.BundlePath != "" { + opts.BundlePath = filepath.Clean(opts.BundlePath) + } +} + +// FetchAttestationsFromGitHubAPI returns true if the command should fetch attestations from the GitHub API +// It checks that a bundle path is not provided and that the "use bundle from registry" flag is not set +func (opts *Options) FetchAttestationsFromGitHubAPI() bool { + return opts.BundlePath == "" && !opts.UseBundleFromRegistry +} + +// AreFlagsValid checks that the provided flag combination is valid +// and returns an error otherwise +func (opts *Options) AreFlagsValid() error { + // If provided, check that the Repo option is in the expected format / + if opts.Repo != "" && !isProvidedRepoValid(opts.Repo) { + return fmt.Errorf("invalid value provided for repo: %s", opts.Repo) + } + + // If provided, check that the SignerRepo option is in the expected format / + if opts.SignerRepo != "" && !isProvidedRepoValid(opts.SignerRepo) { + return fmt.Errorf("invalid value provided for signer-repo: %s", opts.SignerRepo) + } + + // Check that limit is between 1 and 1000 + if opts.Limit < 1 || opts.Limit > 1000 { + return fmt.Errorf("limit %d not allowed, must be between 1 and 1000", opts.Limit) + } + + // Check that the bundle-from-oci flag is only used with OCI artifact paths + if opts.UseBundleFromRegistry && !strings.HasPrefix(opts.ArtifactPath, "oci://") { + return fmt.Errorf("bundle-from-oci flag can only be used with OCI artifact paths") + } + + // Check that both the bundle-from-oci and bundle-path flags are not used together + if opts.UseBundleFromRegistry && opts.BundlePath != "" { + return fmt.Errorf("bundle-from-oci flag cannot be used with bundle-path flag") + } + + // Verify provided hostname + if opts.Hostname != "" { + if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { + return fmt.Errorf("error parsing hostname: %w", err) + } + } + + return nil +} + +func isProvidedRepoValid(repo string) bool { + // we expect a provided repository argument be in the format / + splitRepo := strings.Split(repo, "/") + return len(splitRepo) == 2 +} diff --git a/pkg/cmd/release/verify/policy.go b/pkg/cmd/release/verify/policy.go new file mode 100644 index 000000000..1d1595eca --- /dev/null +++ b/pkg/cmd/release/verify/policy.go @@ -0,0 +1,168 @@ +package verify + +import ( + "errors" + "fmt" + "regexp" + "strings" + + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/verify" + + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" +) + +const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` + +func expandToGitHubURL(tenant, ownerOrRepo string) string { + if tenant == "" { + return fmt.Sprintf("https://github.com/%s", ownerOrRepo) + } + return fmt.Sprintf("https://%s.ghe.com/%s", tenant, ownerOrRepo) +} + +func expandToGitHubURLRegex(tenant, ownerOrRepo string) string { + url := expandToGitHubURL(tenant, ownerOrRepo) + return fmt.Sprintf("(?i)^%s/", url) +} + +func newEnforcementCriteria(opts *Options) (verification.EnforcementCriteria, error) { + // initialize the enforcement criteria with the provided PredicateType + c := verification.EnforcementCriteria{ + PredicateType: opts.PredicateType, + } + + // set the owner value by checking the repo and owner options + var owner string + if opts.Repo != "" { + // we expect the repo argument to be in the format / + splitRepo := strings.Split(opts.Repo, "/") + // if Repo is provided but owner is not, set the OWNER portion of the Repo value + // to Owner + owner = splitRepo[0] + } else { + // otherwise use the user provided owner value + owner = opts.Owner + } + + // Set the SANRegex and SAN values using the provided options + // First check if the opts.SANRegex or opts.SAN values are provided + if opts.SANRegex != "" || opts.SAN != "" { + c.SANRegex = opts.SANRegex + c.SAN = opts.SAN + } else if opts.SignerRepo != "" { + // next check if opts.SignerRepo was provided + signedRepoRegex := expandToGitHubURLRegex(opts.Tenant, opts.SignerRepo) + c.SANRegex = signedRepoRegex + } else if opts.SignerWorkflow != "" { + validatedWorkflowRegex, err := validateSignerWorkflow(opts.Hostname, opts.SignerWorkflow) + if err != nil { + return verification.EnforcementCriteria{}, err + } + c.SANRegex = validatedWorkflowRegex + } else if opts.Repo != "" { + // if the user has not provided the SAN, SANRegex, SignerRepo, or SignerWorkflow options + // then we default to the repo option + c.SANRegex = expandToGitHubURLRegex(opts.Tenant, opts.Repo) + } else { + // if opts.Repo was not provided, we fall back to the opts.Owner value + c.SANRegex = expandToGitHubURLRegex(opts.Tenant, owner) + } + + // if the DenySelfHostedRunner option is set to true, set the + // RunnerEnvironment extension to the GitHub hosted runner value + if opts.DenySelfHostedRunner { + c.Certificate.RunnerEnvironment = verification.GitHubRunner + } else { + // if Certificate.RunnerEnvironment value is set to the empty string + // through the second function argument, + // no certificate matching will happen on the RunnerEnvironment field + c.Certificate.RunnerEnvironment = "" + } + + // If the Repo option is provided, set the SourceRepositoryURI extension + if opts.Repo != "" { + c.Certificate.SourceRepositoryURI = expandToGitHubURL(opts.Tenant, opts.Repo) + } + + // Set the SourceRepositoryOwnerURI extension using owner and tenant if provided + c.Certificate.SourceRepositoryOwnerURI = expandToGitHubURL(opts.Tenant, owner) + + // if the tenant is provided and OIDC issuer provided matches the default + // use the tenant-specific issuer + if opts.Tenant != "" && opts.OIDCIssuer == verification.GitHubOIDCIssuer { + c.Certificate.Issuer = fmt.Sprintf(verification.GitHubTenantOIDCIssuer, opts.Tenant) + } else { + // otherwise use the custom OIDC issuer provided as an option + c.Certificate.Issuer = opts.OIDCIssuer + } + + // 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 +} + +func buildCertificateIdentityOption(c verification.EnforcementCriteria) (verify.PolicyOption, error) { + sanMatcher, err := verify.NewSANMatcher(c.SAN, c.SANRegex) + if err != nil { + return nil, err + } + + // Accept any issuer, we will verify the issuer as part of the extension verification + issuerMatcher, err := verify.NewIssuerMatcher("", ".*") + if err != nil { + return nil, err + } + + extensions := certificate.Extensions{ + RunnerEnvironment: c.Certificate.RunnerEnvironment, + } + + certId, err := verify.NewCertificateIdentity(sanMatcher, issuerMatcher, extensions) + if err != nil { + return nil, err + } + + return verify.WithCertificateIdentity(certId), nil +} + +func buildSigstoreVerifyPolicy(c verification.EnforcementCriteria, a artifact.DigestedArtifact) (verify.PolicyBuilder, error) { + artifactDigestPolicyOption, err := verification.BuildDigestPolicyOption(a) + if err != nil { + return verify.PolicyBuilder{}, err + } + + certIdOption, err := buildCertificateIdentityOption(c) + if err != nil { + return verify.PolicyBuilder{}, err + } + + policy := verify.NewPolicy(artifactDigestPolicyOption, certIdOption) + return policy, nil +} + +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) + if err != nil { + return "", err + } + + if match { + return fmt.Sprintf("^https://%s", signerWorkflow), nil + } + + // if the provided workflow did not match the expect format + // we move onto creating a signer workflow using the provided host name + if hostname == "" { + return "", errors.New("unknown signer workflow host") + } + + return fmt.Sprintf("^https://%s/%s", hostname, signerWorkflow), nil +} diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go new file mode 100644 index 000000000..785b5750a --- /dev/null +++ b/pkg/cmd/release/verify/verify.go @@ -0,0 +1,285 @@ +package verify + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" + ghauth "github.com/cli/go-gh/v2/pkg/auth" + "github.com/spf13/cobra" +) + +type VerifyOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Exporter cmdutil.Exporter + + TagName string +} + +func NewCmdVerify(f *cmdutil.Factory, runF func(*VerifyOptions) error) *cobra.Command { + opts := &VerifyOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "verify []", + Short: "Verify information about a release", + Long: heredoc.Doc(` + Verify information about a GitHub Release. + + Without an explicit tag name argument, the latest release in the project + is shown. + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.TagName = args[0] + } + + if runF != nil { + return runF(opts) + } + return verifyRun(opts) + }, + } + + cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.ReleaseFields) + + return cmd +} + +func verifyRun(opts *VerifyOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + ctx := context.Background() + var release *shared.Release + + if opts.TagName == "" { + return cmdutil.FlagErrorf("tag name is required") + } else { + release, err = shared.FetchRelease(ctx, httpClient, baseRepo, opts.TagName) + if err != nil { + return err + } + } + + sha, err := shared.FetchRefSHA(ctx, httpClient, baseRepo, opts.TagName) + if err != nil { + return err + } + artifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") + + sha = "sha1:" + sha + + // Resolved v1.0.0 to sha1:824acc86dd86a745b3014bd5353b844959f3591e + fmt.Println("Resolved", opts.TagName, "to "+sha) + + // Fetch Attestation + PredicateType := "https://in-toto.io/attestation/release/v0.1" + limit := 10 + + Hostname, _ := ghauth.DefaultHost() + + logger := att_io.NewHandler(opts.IO) + + repo := baseRepo.RepoOwner() + "/" + baseRepo.RepoName() + attestOption := &Options{ + Repo: repo, + APIClient: api.NewLiveClient(httpClient, Hostname, logger), + Limit: limit, + Owner: baseRepo.RepoOwner(), + PredicateType: PredicateType, + } + attestations, logMsg, err := getAttestations(attestOption, sha) + + if err != nil { + if ok := errors.Is(err, api.ErrNoAttestationsFound); ok { + logger.Printf(logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), sha) + return err + } + // Print the message signifying failure fetching attestations + logger.Println(logger.ColorScheme.Red(logMsg)) + return err + } + // Print the message signifying success fetching attestations + logger.Println(logMsg) + + // print information about the policy that will be enforced against attestations + logger.Println("\nThe following policy criteria will be enforced:") + ec, err := newEnforcementCriteria(attestOption) + + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) + return err + } + logger.Println(ec.BuildPolicyInformation()) + + config := verification.SigstoreConfig{ + TrustedRoot: "", + Logger: logger, + NoPublicGood: true, + } + + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + return err + } + verified, errMsg, err := verifyAttestations(*artifact, attestations, sigstoreVerifier, ec) + if err != nil { + logger.Println(logger.ColorScheme.Red(errMsg)) + return err + } + + logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) + + logger.Println(logger.ColorScheme.Green("✓ Verification succeeded!\n")) + + // Verify attestations + + opts.IO.DetectTerminalTheme() + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) + } + defer opts.IO.StopPager() + + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, release) + } + + if opts.IO.IsStdoutTTY() { + if err := renderVerifyTTY(opts.IO, release); err != nil { + return err + } + } else { + if err := renderVerifyPlain(opts.IO.Out, release); err != nil { + return err + } + } + + return nil +} + +func renderVerifyTTY(io *iostreams.IOStreams, release *shared.Release) error { + cs := io.ColorScheme() + w := io.Out + + fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) + if release.IsDraft { + fmt.Fprintf(w, "%s • ", cs.Red("Draft")) + } else if release.IsPrerelease { + fmt.Fprintf(w, "%s • ", cs.Yellow("Pre-release")) + } + if release.IsDraft { + fmt.Fprintln(w, cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))) + } else { + fmt.Fprintln(w, cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))) + } + + renderedDescription, err := markdown.Render(release.Body, + markdown.WithTheme(io.TerminalTheme()), + markdown.WithWrap(io.TerminalWidth())) + if err != nil { + return err + } + fmt.Fprintln(w, renderedDescription) + + if len(release.Assets) > 0 { + 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 { + table.AddField(a.Name) + table.AddField(humanFileSize(a.Size)) + table.EndRow() + } + err := table.Render() + if err != nil { + return err + } + fmt.Fprint(w, "\n") + } + + fmt.Fprintln(w, cs.Mutedf("View on GitHub: %s", release.URL)) + return nil +} + +func renderVerifyPlain(w io.Writer, release *shared.Release) error { + fmt.Fprintf(w, "title:\t%s\n", release.Name) + fmt.Fprintf(w, "tag:\t%s\n", release.TagName) + fmt.Fprintf(w, "draft:\t%v\n", release.IsDraft) + fmt.Fprintf(w, "prerelease:\t%v\n", release.IsPrerelease) + fmt.Fprintf(w, "author:\t%s\n", release.Author.Login) + fmt.Fprintf(w, "created:\t%s\n", release.CreatedAt.Format(time.RFC3339)) + if !release.IsDraft { + fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt.Format(time.RFC3339)) + } + fmt.Fprintf(w, "url:\t%s\n", release.URL) + for _, a := range release.Assets { + fmt.Fprintf(w, "asset:\t%s\n", a.Name) + } + fmt.Fprint(w, "--\n") + fmt.Fprint(w, release.Body) + if !strings.HasSuffix(release.Body, "\n") { + fmt.Fprintf(w, "\n") + } + return nil +} + +func humanFileSize(s int64) string { + if s < 1024 { + return fmt.Sprintf("%d B", s) + } + + kb := float64(s) / 1024 + if kb < 1024 { + return fmt.Sprintf("%s KiB", floatToString(kb, 2)) + } + + mb := kb / 1024 + if mb < 1024 { + return fmt.Sprintf("%s MiB", floatToString(mb, 2)) + } + + gb := mb / 1024 + return fmt.Sprintf("%s GiB", floatToString(gb, 2)) +} + +// render float to fixed precision using truncation instead of rounding +func floatToString(f float64, p uint8) string { + fs := fmt.Sprintf("%#f%0*s", f, p, "") + idx := strings.IndexRune(fs, '.') + return fs[:idx+int(p)+1] +} From a22a1bbde475be05875e3a2ac2524e2ae2a51bce Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 16 May 2025 09:20:58 -0600 Subject: [PATCH 154/249] test(editable): prompts use assignee display names --- pkg/cmd/issue/edit/edit_test.go | 147 +++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 0df6d5e9f..4840cbf7a 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -620,6 +620,123 @@ func Test_editRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/issue/123\n", }, + { + name: "interactive prompts with actor assignee display names when actors available", + input: &EditOptions{ + IssueNumbers: []int{123}, + Interactive: true, + FieldsToEditSurvey: func(p prShared.EditPrompter, eo *prShared.Editable) error { + eo.Assignees.Edited = true + return nil + }, + EditFieldsSurvey: func(p prShared.EditPrompter, eo *prShared.Editable, _ string) error { + // Checking that the display name is being used in the prompt. + require.Equal(t, eo.Assignees.Default, []string{"hubot", "MonaLisa (Mona Display Name)"}) + + // Mocking a selection of only MonaLisa in the prompt. + eo.Assignees.Value = []string{"MonaLisa (Mona Display Name)"} + return nil + }, + FetchOptions: prShared.FetchOptions, + DetermineEditor: func() (string, error) { return "vim", nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIsssueNumberGetWithAssignedActors(t, reg, 123) + reg.Register( + httpmock.GraphQL(`query RepositoryAssignableActors\b`), + httpmock.StringResponse(` + { "data": { "repository": { "suggestedActors": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID", "__typename": "Bot" }, + { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + mockIssueUpdate(t, reg) + reg.Register( + httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`), + httpmock.GraphQLMutation(` + { "data": { "replaceActorsForAssignable": { "__typename": "" } } }`, + func(inputs map[string]interface{}) { + // Checking that despite the display name being returned + // from the EditFieldsSurvey, the ID is still + // used in the mutation. + require.Contains(t, inputs["actorIds"], "MONAID") + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "interactive prompts with user assignee logins when actors unavailable", + input: &EditOptions{ + IssueNumbers: []int{123}, + Interactive: true, + FieldsToEditSurvey: func(p prShared.EditPrompter, eo *prShared.Editable) error { + eo.Assignees.Edited = true + return nil + }, + EditFieldsSurvey: func(p prShared.EditPrompter, eo *prShared.Editable, _ string) error { + // Checking that only the login is used in the prompt (no display name) + require.Equal(t, eo.Assignees.Default, []string{"hubot", "MonaLisa"}) + + // Mocking a selection of only MonaLisa in the prompt. + eo.Assignees.Value = []string{"MonaLisa"} + return nil + }, + FetchOptions: prShared.FetchOptions, + DetermineEditor: func() (string, error) { return "vim", nil }, + Detector: &fd.DisabledDetectorMock{}, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(fmt.Sprintf(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "id": "%[1]d", + "number": %[1]d, + "url": "https://github.com/OWNER/REPO/issue/123", + "assignees": { + "nodes": [ + { + "id": "HUBOTID", + "login": "hubot", + "name": "" + }, + { + "id": "MONAID", + "login": "MonaLisa", + "name": "Mona Display Name" + } + ], + "totalCount": 2 + } + } } } }`, 123)), + ) + reg.Register( + httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + httpmock.StringResponse(` + { "data": { "repository": { "assignableUsers": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID", "name": "" }, + { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`mutation IssueUpdate\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssue": { "__typename": "" } } }`, + func(inputs map[string]interface{}) { + // Checking that we still assigned the expected ID. + require.Contains(t, inputs["assigneeIds"], "MONAID") + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -678,6 +795,34 @@ func mockIssueNumberGet(_ *testing.T, reg *httpmock.Registry, number int) { ) } +func mockIsssueNumberGetWithAssignedActors(_ *testing.T, reg *httpmock.Registry, number int) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(fmt.Sprintf(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "id": "%[1]d", + "number": %[1]d, + "url": "https://github.com/OWNER/REPO/issue/%[1]d", + "assignedActors": { + "nodes": [ + { + "id": "HUBOTID", + "login": "hubot", + "__typename": "Bot" + }, + { + "id": "MONAID", + "login": "MonaLisa", + "name": "Mona Display Name", + "__typename": "User" + } + ], + "totalCount": 2 + } + } } } }`, number)), + ) +} + func mockIssueProjectItemsGet(_ *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query IssueProjectItems\b`), @@ -699,7 +844,7 @@ func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) { { "data": { "repository": { "suggestedActors": { "nodes": [ { "login": "hubot", "id": "HUBOTID", "__typename": "Bot" }, - { "login": "MonaLisa", "id": "MONAID", "__typename": "User" } + { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" } ], "pageInfo": { "hasNextPage": false } } } } } From c5f2d7bde70115a15135578a026b765f5c4c3429 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 16 May 2025 09:37:20 -0600 Subject: [PATCH 155/249] doc(editable): remove needless comment --- pkg/cmd/pr/shared/editable.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 1bbcd3113..4b190cae8 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -120,7 +120,6 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str meReplacer := NewMeReplacer(client, repo.RepoHost()) copilotReplacer := NewCopilotReplacer() - // A closure to replace special assignee names with the actual logins. replaceSpecialAssigneeNames := func(value []string) ([]string, error) { replaced, err := meReplacer.ReplaceSlice(value) if err != nil { From 871671be526e7b3e40f90a5a562a5eb75136f0a6 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 16 May 2025 09:40:29 -0600 Subject: [PATCH 156/249] fix(params): remove needless err return --- pkg/cmd/pr/shared/editable.go | 5 +---- pkg/cmd/pr/shared/params.go | 16 ++++++---------- pkg/cmd/pr/shared/params_test.go | 3 +-- 3 files changed, 8 insertions(+), 16 deletions(-) diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 4b190cae8..c71f89b52 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -128,10 +128,7 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str // Only suppported for actor assignees. if e.Assignees.ActorAssignees { - replaced, err = copilotReplacer.ReplaceSlice(replaced) - if err != nil { - return nil, err - } + replaced = copilotReplacer.ReplaceSlice(replaced) } return replaced, nil diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 7ea364707..e53ad7a06 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -320,22 +320,18 @@ func NewCopilotReplacer() *CopilotReplacer { return &CopilotReplacer{} } -func (r *CopilotReplacer) replace(handle string) (string, error) { +func (r *CopilotReplacer) replace(handle string) string { if strings.EqualFold(handle, "@copilot") { - return "copilot-swe-agent", nil + return "copilot-swe-agent" } - return handle, nil + return handle } // Replace replaces usages of `@copilot` in a slice with Copilot's login. -func (r *CopilotReplacer) ReplaceSlice(handles []string) ([]string, error) { +func (r *CopilotReplacer) ReplaceSlice(handles []string) []string { res := make([]string, len(handles)) for i, h := range handles { - var err error - res[i], err = r.replace(h) - if err != nil { - return nil, err - } + res[i] = r.replace(h) } - return res, nil + return res } diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go index b1e3d32d6..4a353e97e 100644 --- a/pkg/cmd/pr/shared/params_test.go +++ b/pkg/cmd/pr/shared/params_test.go @@ -233,8 +233,7 @@ func TestCopilotReplacer_ReplaceSlice(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := NewCopilotReplacer() - got, err := r.ReplaceSlice(tt.args.handles) - require.NoError(t, err) + got := r.ReplaceSlice(tt.args.handles) require.Equal(t, tt.want, got) }) } From debf0bbaf22b7293da97b10d0ad01f1c64e05904 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 16 May 2025 09:50:24 -0600 Subject: [PATCH 157/249] test(params): enhance Copilot replacer tests for edge cases - Added tests for handling nil and empty slices in the Copilot replacer. - Simplified test structure by removing unnecessary wantErr field. --- pkg/cmd/pr/shared/params_test.go | 33 ++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go index 4a353e97e..53eb6328f 100644 --- a/pkg/cmd/pr/shared/params_test.go +++ b/pkg/cmd/pr/shared/params_test.go @@ -192,42 +192,51 @@ func TestCopilotReplacer_ReplaceSlice(t *testing.T) { handles []string } tests := []struct { - name string - args args - want []string - wantErr bool + name string + args args + want []string }{ { name: "replaces @copilot with copilot-swe-agent", args: args{ handles: []string{"monalisa", "@copilot", "hubot"}, }, - want: []string{"monalisa", "copilot-swe-agent", "hubot"}, - wantErr: false, + want: []string{"monalisa", "copilot-swe-agent", "hubot"}, }, { name: "handles no @copilot mentions", args: args{ handles: []string{"monalisa", "user", "hubot"}, }, - want: []string{"monalisa", "user", "hubot"}, - wantErr: false, + want: []string{"monalisa", "user", "hubot"}, }, { name: "replaces multiple @copilot mentions", args: args{ handles: []string{"@copilot", "user", "@copilot"}, }, - want: []string{"copilot-swe-agent", "user", "copilot-swe-agent"}, - wantErr: false, + want: []string{"copilot-swe-agent", "user", "copilot-swe-agent"}, }, { name: "handles @copilot case-insensitively", args: args{ handles: []string{"@Copilot", "user", "@CoPiLoT"}, }, - want: []string{"copilot-swe-agent", "user", "copilot-swe-agent"}, - wantErr: false, + want: []string{"copilot-swe-agent", "user", "copilot-swe-agent"}, + }, + { + name: "handles nil slice", + args: args{ + handles: nil, + }, + want: []string{}, + }, + { + name: "handles empty slice", + args: args{ + handles: []string{}, + }, + want: []string{}, }, } for _, tt := range tests { From 532aa1c32a8602653f2e12e665e3b50f66d57714 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 16 May 2025 09:55:17 -0600 Subject: [PATCH 158/249] doc(issue/pr edit): doc `@copilot` assignee --- pkg/cmd/issue/edit/edit.go | 6 ++++++ pkg/cmd/pr/edit/edit.go | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 8fc8a2e41..36260cb1b 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -60,11 +60,17 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman Editing issues' projects requires authorization with the %[1]sproject%[1]s scope. To authorize, run %[1]sgh auth refresh -s project%[1]s. + + The %[1]s--add-assignee%[1]s and %[1]s--remove-assignee%[1]s flags both support + the following special values: + - %[1]s@me%[1]s: assign or unassign yourself + - %[1]s@copilot%[1]s: assign or unassign Copilot `, "`"), Example: heredoc.Doc(` $ gh issue edit 23 --title "I found a bug" --body "Nothing works" $ gh issue edit 23 --add-label "bug,help wanted" --remove-label "core" $ gh issue edit 23 --add-assignee "@me" --remove-assignee monalisa,hubot + $ gh issue edit 23 --add-assignee "@copilot" $ gh issue edit 23 --add-project "Roadmap" --remove-project v1,v2 $ gh issue edit 23 --milestone "Version 1" $ gh issue edit 23 --remove-milestone diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 155f08897..92f40645e 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -60,12 +60,18 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman Editing a pull request's projects requires authorization with the %[1]sproject%[1]s scope. To authorize, run %[1]sgh auth refresh -s project%[1]s. + + The %[1]s--add-assignee%[1]s and %[1]s--remove-assignee%[1]s flags both support + the following special values: + - %[1]s@me%[1]s: assign or unassign yourself + - %[1]s@copilot%[1]s: assign or unassign Copilot `, "`"), Example: heredoc.Doc(` $ gh pr edit 23 --title "I found a bug" --body "Nothing works" $ gh pr edit 23 --add-label "bug,help wanted" --remove-label "core" $ gh pr edit 23 --add-reviewer monalisa,hubot --remove-reviewer myorg/team-name $ gh pr edit 23 --add-assignee "@me" --remove-assignee monalisa,hubot + $ gh pr edit 23 --add-assignee "@copilot" $ gh pr edit 23 --add-project "Roadmap" --remove-project v1,v2 $ gh pr edit 23 --milestone "Version 1" $ gh pr edit 23 --remove-milestone From 748afb6e85cf56bd6ad4ae6f348dcfd7d105192f Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 16 May 2025 12:18:23 -0600 Subject: [PATCH 159/249] doc(issue/pr edit): clarify @copilot usage --- pkg/cmd/issue/edit/edit.go | 2 +- pkg/cmd/pr/edit/edit.go | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 36260cb1b..e959cde2b 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -64,7 +64,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman The %[1]s--add-assignee%[1]s and %[1]s--remove-assignee%[1]s flags both support the following special values: - %[1]s@me%[1]s: assign or unassign yourself - - %[1]s@copilot%[1]s: assign or unassign Copilot + - %[1]s@copilot%[1]s: assign or unassign Copilot (not supported on GitHub Enterprise Server) `, "`"), Example: heredoc.Doc(` $ gh issue edit 23 --title "I found a bug" --body "Nothing works" diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 92f40645e..c0dc61199 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -64,7 +64,10 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman The %[1]s--add-assignee%[1]s and %[1]s--remove-assignee%[1]s flags both support the following special values: - %[1]s@me%[1]s: assign or unassign yourself - - %[1]s@copilot%[1]s: assign or unassign Copilot + - %[1]s@copilot%[1]s: assign or unassign Copilot (not supported on GitHub Enterprise Server) + + The %[1]s--add-reviewer%[1]s and %[1]s--remove-reviewer%[1]s flags do not support + these special values. `, "`"), Example: heredoc.Doc(` $ gh pr edit 23 --title "I found a bug" --body "Nothing works" From 510ce73d6efce407a2d011192052715d388bc075 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 16 May 2025 14:22:45 -0400 Subject: [PATCH 160/249] wip --- pkg/cmd/attestation/verification/sigstore.go | 3 + pkg/cmd/release/verify/attestation.go | 12 +-- pkg/cmd/release/verify/verify.go | 80 +++++++++++++++++++- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 190ea5c0f..14c8875d9 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -239,6 +239,9 @@ func (v *LiveSigstoreVerifier) verify(attestation *api.Attestation, policy verif result, err := verifier.Verify(attestation.Bundle, policy) // if verification fails, create the error and exit verification early if err != nil { + v.Logger.VerbosePrint(v.Logger.ColorScheme.Redf( + "Error is \"%s\"\n", err.Error(), + )) v.Logger.VerbosePrint(v.Logger.ColorScheme.Redf( "Failed to verify against issuer \"%s\" \n\n", issuer, )) diff --git a/pkg/cmd/release/verify/attestation.go b/pkg/cmd/release/verify/attestation.go index 9aa7461ae..8b4931f9a 100644 --- a/pkg/cmd/release/verify/attestation.go +++ b/pkg/cmd/release/verify/attestation.go @@ -48,11 +48,11 @@ func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, s } // Verify extensions - certExtVerified, err := verification.VerifyCertExtensions(sigstoreVerified, ec) - if err != nil { - logMsg := "✗ Policy verification failed" - return nil, logMsg, err - } + // certExtVerified, err := verification.VerifyCertExtensions(sigstoreVerified, ec) + // if err != nil { + // logMsg := "✗ Policy verification failed" + // return nil, logMsg, err + // } - return certExtVerified, "", nil + return sigstoreVerified, "", nil } diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 785b5750a..3e0f739d5 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -9,7 +9,10 @@ import ( "strings" "time" + v1 "github.com/in-toto/attestation/go/v1" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "google.golang.org/protobuf/encoding/protojson" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" @@ -136,15 +139,22 @@ func verifyRun(opts *VerifyOptions) error { // Print the message signifying success fetching attestations logger.Println(logMsg) + td, err := attestOption.APIClient.GetTrustDomain() + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to get trust domain")) + return err + } + // print information about the policy that will be enforced against attestations - logger.Println("\nThe following policy criteria will be enforced:") + // logger.Println("\nThe following policy criteria will be enforced:") ec, err := newEnforcementCriteria(attestOption) + ec.SANRegex = "https://dotcom.releases.github.com" if err != nil { logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) return err } - logger.Println(ec.BuildPolicyInformation()) + // logger.Println(ec.BuildPolicyInformation()) config := verification.SigstoreConfig{ TrustedRoot: "", @@ -152,12 +162,41 @@ func verifyRun(opts *VerifyOptions) error { NoPublicGood: true, } + config.TrustDomain = td + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) return err } - verified, errMsg, err := verifyAttestations(*artifact, attestations, sigstoreVerifier, ec) + + var filteredAttestations []*api.Attestation + + for _, att := range attestations { + statement := att.Bundle.Bundle.GetDsseEnvelope().Payload + + var statementData v1.Statement + err = protojson.Unmarshal([]byte(statement), &statementData) + + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + return err + } + expectedPURL := "pkg:github/" + attestOption.Repo + "@" + opts.TagName + purlValue := statementData.Predicate.GetFields()["purl"] + var purl string + if purlValue != nil { + purl = purlValue.GetStringValue() + } + + // fmt.Print("purlValue: ", expectedPURL, "\n") + // fmt.Print("purl: ", purl, "\n") + if purl == expectedPURL { + filteredAttestations = append(filteredAttestations, att) + } + } + + verified, errMsg, err := verifyAttestations(*artifact, filteredAttestations, sigstoreVerifier, ec) if err != nil { logger.Println(logger.ColorScheme.Red(errMsg)) return err @@ -167,6 +206,39 @@ func verifyRun(opts *VerifyOptions) error { logger.Println(logger.ColorScheme.Green("✓ Verification succeeded!\n")) + // Print verified attestations + for _, att := range verified { + + // • {"_type":"https://in-toto.io/Statement/v1", "subject":[{"name":"pkg:github/bdehamer/delme@v2.0.0", "digest":{"sha1":"c5e17a62e06a1d201570249c61fae531e9244e1b"}}, {"name":"bdehamer-attest-demo-attestation-6498970.sigstore.1.json", "digest":{"sha256":"b41c3c570a2f60272cb387a58f3e574c6f9da913f6281204b67a223e6ae56176"}}], "predicateType":"https://in-toto.io/attestation/release/v0.1", "predicate":{"ownerId":"398027", "purl":"pkg:github/bdehamer/delme@v2.0.0", "releaseId":"217656813", "repository":"bdehamer/delme", "repositoryId":"905988044", "tag":"v2.0.0"}} + statement := att.Attestation.Bundle.GetDsseEnvelope().Payload + + // cast statement to {"_type":"https://in-toto.io/Statement/v1", "subject":[{"name":"pkg:github/bdehamer/delme@v2.0.0", "digest":{"sha1":"c5e17a62e06a1d201570249c61fae531e9244e1b"}}, {"name":"bdehamer-attest-demo-attestation-6498970.sigstore.1.json", "digest":{"sha256":"b41c3c570a2f60272cb387a58f3e574c6f9da913f6281204b67a223e6ae56176"}}], "predicateType":"https://in-toto.io/attestation/release/v0.1", "predicate":{"ownerId":"398027", "purl":"pkg:github/bdehamer/delme@v2.0.0", "releaseId":"217656813", "repository":"bdehamer/delme", "repositoryId":"905988044", "tag":"v2.0.0"}} + + var statementData v1.Statement + err = protojson.Unmarshal([]byte(statement), &statementData) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + return err + } + + subjects := statementData.Subject + + for _, s := range subjects { + // // Print the subject name and digest + // logger.Printf("• %s\n", s.Name) + // for k, v := range s.Digest { + // // Print the digest algorithm and value + // logger.Printf(" - %s: %s\n", k, v) + // } + + // Print the whole subject + logger.Printf("%s\n", s.String()) + } + + // logger.Printf("• %s\n", att.Attestation.Bundle.GetDsseEnvelope().Payload) + + } + // Verify attestations opts.IO.DetectTerminalTheme() @@ -196,7 +268,7 @@ func renderVerifyTTY(io *iostreams.IOStreams, release *shared.Release) error { cs := io.ColorScheme() w := io.Out - fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) + // fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) if release.IsDraft { fmt.Fprintf(w, "%s • ", cs.Red("Draft")) } else if release.IsPrerelease { From a28e2d87840f275a2c4e1d216bce06aeabb7b011 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 16 May 2025 12:28:46 -0600 Subject: [PATCH 161/249] refactor(api): use constant for Copilot login --- api/queries_repo.go | 7 ++++++- pkg/cmd/pr/shared/params.go | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index f72277fb2..28d4374b7 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -1183,6 +1183,11 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) return projects, nil } +// Expected login for Copilot when retrieved as an Actor +// This is returned from assignable actors and issue/pr assigned actors. +// We use this to check if the actor is Copilot. +var CopilotActorLogin = "copilot-swe-agent" + type AssignableActor interface { DisplayName() string ID() string @@ -1241,7 +1246,7 @@ func NewAssignableBot(id, login string) AssignableBot { } func (b AssignableBot) DisplayName() string { - if b.login == "copilot-swe-agent" { + if b.login == CopilotActorLogin { return "Copilot (AI)" } return b.Login() diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index e53ad7a06..ba9901f77 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -322,7 +322,7 @@ func NewCopilotReplacer() *CopilotReplacer { func (r *CopilotReplacer) replace(handle string) string { if strings.EqualFold(handle, "@copilot") { - return "copilot-swe-agent" + return api.CopilotActorLogin } return handle } From 6f1906073f2281ae1a0ce63566d9c5911f5d19b5 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 16 May 2025 12:51:09 -0600 Subject: [PATCH 162/249] doc(params): incorrect func name in comment Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/cmd/pr/shared/params.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index ba9901f77..1fa45652a 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -327,7 +327,7 @@ func (r *CopilotReplacer) replace(handle string) string { return handle } -// Replace replaces usages of `@copilot` in a slice with Copilot's login. +// ReplaceSlice replaces usages of `@copilot` in a slice with Copilot's login. func (r *CopilotReplacer) ReplaceSlice(handles []string) []string { res := make([]string, len(handles)) for i, h := range handles { From 53d0d5d192dcb3f0edf16b70bc1586b6ea71faa1 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 16 May 2025 12:53:56 -0600 Subject: [PATCH 163/249] fix(editable): include DefaultLogins in EditableAssignees clone --- pkg/cmd/pr/shared/editable.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index c71f89b52..2f51f2ae8 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -292,6 +292,7 @@ func (ea *EditableAssignees) clone() EditableAssignees { return EditableAssignees{ EditableSlice: ea.EditableSlice.clone(), ActorAssignees: ea.ActorAssignees, + DefaultLogins: ea.DefaultLogins, } } From 74c6a36c20cdd14a64599fa6f8e996e1b3b06bf4 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 16 May 2025 14:59:04 -0400 Subject: [PATCH 164/249] remove comment --- pkg/cmd/release/verify/verify.go | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 3e0f739d5..e26b1d50c 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -154,7 +154,6 @@ func verifyRun(opts *VerifyOptions) error { logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) return err } - // logger.Println(ec.BuildPolicyInformation()) config := verification.SigstoreConfig{ TrustedRoot: "", @@ -170,6 +169,7 @@ func verifyRun(opts *VerifyOptions) error { return err } + // Filter attestations by predicate PURL var filteredAttestations []*api.Attestation for _, att := range attestations { @@ -196,6 +196,7 @@ func verifyRun(opts *VerifyOptions) error { } } + // Verify attestations verified, errMsg, err := verifyAttestations(*artifact, filteredAttestations, sigstoreVerifier, ec) if err != nil { logger.Println(logger.ColorScheme.Red(errMsg)) @@ -208,12 +209,8 @@ func verifyRun(opts *VerifyOptions) error { // Print verified attestations for _, att := range verified { - - // • {"_type":"https://in-toto.io/Statement/v1", "subject":[{"name":"pkg:github/bdehamer/delme@v2.0.0", "digest":{"sha1":"c5e17a62e06a1d201570249c61fae531e9244e1b"}}, {"name":"bdehamer-attest-demo-attestation-6498970.sigstore.1.json", "digest":{"sha256":"b41c3c570a2f60272cb387a58f3e574c6f9da913f6281204b67a223e6ae56176"}}], "predicateType":"https://in-toto.io/attestation/release/v0.1", "predicate":{"ownerId":"398027", "purl":"pkg:github/bdehamer/delme@v2.0.0", "releaseId":"217656813", "repository":"bdehamer/delme", "repositoryId":"905988044", "tag":"v2.0.0"}} statement := att.Attestation.Bundle.GetDsseEnvelope().Payload - // cast statement to {"_type":"https://in-toto.io/Statement/v1", "subject":[{"name":"pkg:github/bdehamer/delme@v2.0.0", "digest":{"sha1":"c5e17a62e06a1d201570249c61fae531e9244e1b"}}, {"name":"bdehamer-attest-demo-attestation-6498970.sigstore.1.json", "digest":{"sha256":"b41c3c570a2f60272cb387a58f3e574c6f9da913f6281204b67a223e6ae56176"}}], "predicateType":"https://in-toto.io/attestation/release/v0.1", "predicate":{"ownerId":"398027", "purl":"pkg:github/bdehamer/delme@v2.0.0", "releaseId":"217656813", "repository":"bdehamer/delme", "repositoryId":"905988044", "tag":"v2.0.0"}} - var statementData v1.Statement err = protojson.Unmarshal([]byte(statement), &statementData) if err != nil { @@ -224,23 +221,10 @@ func verifyRun(opts *VerifyOptions) error { subjects := statementData.Subject for _, s := range subjects { - // // Print the subject name and digest - // logger.Printf("• %s\n", s.Name) - // for k, v := range s.Digest { - // // Print the digest algorithm and value - // logger.Printf(" - %s: %s\n", k, v) - // } - - // Print the whole subject logger.Printf("%s\n", s.String()) } - - // logger.Printf("• %s\n", att.Attestation.Bundle.GetDsseEnvelope().Payload) - } - // Verify attestations - opts.IO.DetectTerminalTheme() if err := opts.IO.StartPager(); err != nil { fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) From e0f3f0f2fde2f0ae0695d53f0b00e8f15006dd47 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 16 May 2025 14:57:49 -0600 Subject: [PATCH 165/249] refactor(api): change CopilotActorLogin to constant --- api/queries_repo.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 28d4374b7..2b2a1eb96 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -1186,7 +1186,7 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) // Expected login for Copilot when retrieved as an Actor // This is returned from assignable actors and issue/pr assigned actors. // We use this to check if the actor is Copilot. -var CopilotActorLogin = "copilot-swe-agent" +const CopilotActorLogin = "copilot-swe-agent" type AssignableActor interface { DisplayName() string From 0788a015176a73adbc5db76a82b6f854a0480742 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 16 May 2025 15:17:39 -0600 Subject: [PATCH 166/249] refactor(api): inline struct definitions in RepoAssignableActors --- api/queries_repo.go | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 2b2a1eb96..efbcfcb19 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -1319,25 +1319,21 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]AssignableUse // RepoAssignableActors fetches all the assignable actors for a repository on // GitHub hosts that support Actor assignees. func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]AssignableActor, error) { - type assignableUser struct { - ID string - Login string - Name string - TypeName string `graphql:"__typename"` - } - - type assignableBot struct { - ID string - Login string - TypeName string `graphql:"__typename"` - } - type responseData struct { Repository struct { SuggestedActors struct { Nodes []struct { - User assignableUser `graphql:"... on User"` - Bot assignableBot `graphql:"... on Bot"` + User struct { + ID string + Login string + Name string + TypeName string `graphql:"__typename"` + } `graphql:"... on User"` + Bot struct { + ID string + Login string + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` } PageInfo struct { HasNextPage bool From b639d6d5149c9f1fc61837b92c26e0bc7dff0ad7 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 20 May 2025 08:37:01 -0600 Subject: [PATCH 167/249] doc(pr): format allowed values and defaults in help --- pkg/cmd/config/config.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go index 66051f83a..92d0ca1ba 100644 --- a/pkg/cmd/config/config.go +++ b/pkg/cmd/config/config.go @@ -20,10 +20,10 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { for _, co := range config.Options { longDoc.WriteString(fmt.Sprintf("- `%s`: %s", co.Key, co.Description)) if len(co.AllowedValues) > 0 { - longDoc.WriteString(fmt.Sprintf(" {%s}", strings.Join(co.AllowedValues, "|"))) + longDoc.WriteString(fmt.Sprintf(" `{%s}`", strings.Join(co.AllowedValues, " | "))) } if co.DefaultValue != "" { - longDoc.WriteString(fmt.Sprintf(" (default %s)", co.DefaultValue)) + longDoc.WriteString(fmt.Sprintf(" (default `%s`)", co.DefaultValue)) } longDoc.WriteRune('\n') } From 944543863af83332ffaea84692eaec130a89a9cb Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 20 May 2025 08:54:30 -0600 Subject: [PATCH 168/249] Revert "[gh config] Escape pipe symbol in Long desc for website manual" --- internal/docs/markdown.go | 3 +-- pkg/cmd/config/config.go | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/docs/markdown.go b/internal/docs/markdown.go index 05d8686b8..7ae8c6862 100644 --- a/internal/docs/markdown.go +++ b/internal/docs/markdown.go @@ -142,8 +142,7 @@ func genMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string) fmt.Fprintf(w, "```\n%s\n```\n\n", cmd.UseLine()) } if hasLong { - longWithEscapedPipe := strings.ReplaceAll(cmd.Long, "|", "|") - fmt.Fprintf(w, "%s\n\n", longWithEscapedPipe) + fmt.Fprintf(w, "%s\n\n", cmd.Long) } for _, g := range root.GroupedCommands(cmd) { diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go index 66051f83a..2661c3369 100644 --- a/pkg/cmd/config/config.go +++ b/pkg/cmd/config/config.go @@ -16,7 +16,7 @@ import ( func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { longDoc := strings.Builder{} longDoc.WriteString("Display or change configuration settings for gh.\n\n") - longDoc.WriteString("Current respected settings:\n\n") + longDoc.WriteString("Current respected settings:\n") for _, co := range config.Options { longDoc.WriteString(fmt.Sprintf("- `%s`: %s", co.Key, co.Description)) if len(co.AllowedValues) > 0 { From 26b46f939deb42f62168b087265f88ba63479620 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Tue, 20 May 2025 11:12:46 -0400 Subject: [PATCH 169/249] wip --- .../{verify => attestation}/attestation.go | 33 +- pkg/cmd/release/attestation/options.go | 92 ++++++ pkg/cmd/release/attestation/policy.go | 86 +++++ pkg/cmd/release/verify-asset/verify-asset.go | 308 ++++++++++++++++++ pkg/cmd/release/verify/options.go | 104 ------ pkg/cmd/release/verify/policy.go | 168 ---------- pkg/cmd/release/verify/verify.go | 269 +++++++-------- 7 files changed, 634 insertions(+), 426 deletions(-) rename pkg/cmd/release/{verify => attestation}/attestation.go (59%) create mode 100644 pkg/cmd/release/attestation/options.go create mode 100644 pkg/cmd/release/attestation/policy.go create mode 100644 pkg/cmd/release/verify-asset/verify-asset.go delete mode 100644 pkg/cmd/release/verify/options.go delete mode 100644 pkg/cmd/release/verify/policy.go diff --git a/pkg/cmd/release/verify/attestation.go b/pkg/cmd/release/attestation/attestation.go similarity index 59% rename from pkg/cmd/release/verify/attestation.go rename to pkg/cmd/release/attestation/attestation.go index 8b4931f9a..a8c654f46 100644 --- a/pkg/cmd/release/verify/attestation.go +++ b/pkg/cmd/release/attestation/attestation.go @@ -1,4 +1,4 @@ -package verify +package attestation import ( "errors" @@ -8,9 +8,13 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + + att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" + v1 "github.com/in-toto/attestation/go/v1" + "google.golang.org/protobuf/encoding/protojson" ) -func getAttestations(o *Options, sha string) ([]*api.Attestation, string, error) { +func GetAttestations(o *AttestOptions, sha string) ([]*api.Attestation, string, error) { if o.APIClient == nil { errMsg := "✗ No APIClient provided" return nil, errMsg, errors.New(errMsg) @@ -34,7 +38,7 @@ func getAttestations(o *Options, sha string) ([]*api.Attestation, string, error) return attestations, msg, nil } -func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { +func VerifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { sgPolicy, err := buildSigstoreVerifyPolicy(ec, art) if err != nil { logMsg := "✗ Failed to build Sigstore verification policy" @@ -56,3 +60,26 @@ func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, s return sigstoreVerified, "", nil } + +func FilterAttestationsByPURL(attestations []*api.Attestation, repo, tagName string, logger *att_io.Handler) []*api.Attestation { + var filtered []*api.Attestation + expectedPURL := "pkg:github/" + repo + "@" + tagName + for _, att := range attestations { + statement := att.Bundle.Bundle.GetDsseEnvelope().Payload + var statementData v1.Statement + err := protojson.Unmarshal([]byte(statement), &statementData) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + continue + } + purlValue := statementData.Predicate.GetFields()["purl"] + var purl string + if purlValue != nil { + purl = purlValue.GetStringValue() + } + if purl == expectedPURL { + filtered = append(filtered, att) + } + } + return filtered +} diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/attestation/options.go new file mode 100644 index 000000000..c567ff7e6 --- /dev/null +++ b/pkg/cmd/release/attestation/options.go @@ -0,0 +1,92 @@ +package attestation + +import ( + "fmt" + "net/http" + "strings" + + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" + "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" +) + +type VerifyOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Exporter cmdutil.Exporter + TagName string +} + +// AttestOptions captures the options for the verify command +type AttestOptions struct { + Config func() (gh.Config, error) + HttpClient *http.Client + IO *iostreams.IOStreams + BaseRepo ghrepo.Interface + Exporter cmdutil.Exporter + TagName string + TrustedRoot string + DigestAlgorithm string + Limit int + OIDCIssuer string + Owner string + PredicateType string + Repo string + SAN string + SANRegex string + SignerDigest string + SignerRepo string + SignerWorkflow string + SourceDigest string + SourceRef string + APIClient api.Client + Logger *io.Handler + OCIClient oci.Client + SigstoreVerifier verification.SigstoreVerifier + exporter cmdutil.Exporter + Hostname string + EC verification.EnforcementCriteria + // Tenant is only set when tenancy is used + Tenant string +} + +// AreFlagsValid checks that the provided flag combination is valid +// and returns an error otherwise +func (opts *AttestOptions) AreFlagsValid() error { + // If provided, check that the Repo option is in the expected format / + if opts.Repo != "" && !isProvidedRepoValid(opts.Repo) { + return fmt.Errorf("invalid value provided for repo: %s", opts.Repo) + } + + // If provided, check that the SignerRepo option is in the expected format / + if opts.SignerRepo != "" && !isProvidedRepoValid(opts.SignerRepo) { + return fmt.Errorf("invalid value provided for signer-repo: %s", opts.SignerRepo) + } + + // Check that limit is between 1 and 1000 + if opts.Limit < 1 || opts.Limit > 1000 { + return fmt.Errorf("limit %d not allowed, must be between 1 and 1000", opts.Limit) + } + + // Verify provided hostname + if opts.Hostname != "" { + if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { + return fmt.Errorf("error parsing hostname: %w", err) + } + } + + return nil +} + +func isProvidedRepoValid(repo string) bool { + // we expect a provided repository argument be in the format / + splitRepo := strings.Split(repo, "/") + return len(splitRepo) == 2 +} diff --git a/pkg/cmd/release/attestation/policy.go b/pkg/cmd/release/attestation/policy.go new file mode 100644 index 000000000..f875acf08 --- /dev/null +++ b/pkg/cmd/release/attestation/policy.go @@ -0,0 +1,86 @@ +package attestation + +import ( + "fmt" + + att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/verify" + + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" +) + +const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` + +func expandToGitHubURL(tenant, ownerOrRepo string) string { + if tenant == "" { + return fmt.Sprintf("https://github.com/%s", ownerOrRepo) + } + return fmt.Sprintf("https://%s.ghe.com/%s", tenant, ownerOrRepo) +} + +// TODO: revisit this policy +func expandToGitHubURLRegex(tenant, ownerOrRepo string) string { + url := expandToGitHubURL(tenant, ownerOrRepo) + return fmt.Sprintf("(?i)^%s/", url) +} + +// TODO: revist this policy +func NewEnforcementCriteria(opts *AttestOptions, logger *att_io.Handler) (verification.EnforcementCriteria, error) { + // initialize the enforcement criteria with the provided PredicateType and SAN + c := verification.EnforcementCriteria{ + PredicateType: opts.PredicateType, + // if the proxima is provided, the default uses the proxima-specific SAN + SAN: "https://dotcom.releases.github.com", + } + + // If the Repo option is provided, set the SourceRepositoryURI extension + if opts.Repo != "" { + c.Certificate.SourceRepositoryURI = expandToGitHubURL(opts.Tenant, opts.Repo) + } + + // Set the SourceRepositoryOwnerURI extension using owner and tenant if provided + c.Certificate.SourceRepositoryOwnerURI = expandToGitHubURL(opts.Tenant, opts.Owner) + + return c, nil +} + +func buildCertificateIdentityOption(c verification.EnforcementCriteria) (verify.PolicyOption, error) { + sanMatcher, err := verify.NewSANMatcher(c.SAN, c.SANRegex) + if err != nil { + return nil, err + } + + // Accept any issuer, we will verify the issuer as part of the extension verification + issuerMatcher, err := verify.NewIssuerMatcher("", ".*") + if err != nil { + return nil, err + } + + extensions := certificate.Extensions{ + RunnerEnvironment: c.Certificate.RunnerEnvironment, + } + + certId, err := verify.NewCertificateIdentity(sanMatcher, issuerMatcher, extensions) + if err != nil { + return nil, err + } + + return verify.WithCertificateIdentity(certId), nil +} + +func buildSigstoreVerifyPolicy(c verification.EnforcementCriteria, a artifact.DigestedArtifact) (verify.PolicyBuilder, error) { + artifactDigestPolicyOption, err := verification.BuildDigestPolicyOption(a) + if err != nil { + return verify.PolicyBuilder{}, err + } + + certIdOption, err := buildCertificateIdentityOption(c) + if err != nil { + return verify.PolicyBuilder{}, err + } + + policy := verify.NewPolicy(artifactDigestPolicyOption, certIdOption) + return policy, nil +} diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go new file mode 100644 index 000000000..3b7d1e8e9 --- /dev/null +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -0,0 +1,308 @@ +package verify_asset + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + v1 "github.com/in-toto/attestation/go/v1" + "google.golang.org/protobuf/encoding/protojson" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmd/release/attestation" + "github.com/cli/cli/v2/pkg/cmd/release/shared" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" + ghauth "github.com/cli/go-gh/v2/pkg/auth" + "github.com/spf13/cobra" +) + +func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) error) *cobra.Command { + opts := &attestation.VerifyOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "verify-asset []", + Short: "Verify information about a release", + Long: heredoc.Doc(` + Verify information about a GitHub Release. + + Without an explicit tag name argument, the latest release in the project + is shown. + `), + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + // Create a logger for use throughout the verify command + // opts.Logger = io.NewHandler(f.IOStreams) + + // // set the artifact path + // opts.ArtifactPath = args[0] + + // // Check that the given flag combination is valid + // if err := opts.AreFlagsValid(); err != nil { + // return err + // } + + // // Clean file path options + // opts.Clean() + + // if opts.TagName == "" { + // return cmdutil.FlagErrorf("tag name is required") + // } + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.TagName = args[0] + } + + if runF != nil { + return runF(opts) + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + logger := att_io.NewHandler(opts.IO) + hostname, _ := ghauth.DefaultHost() + option := attestation.AttestOptions{ + Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), + APIClient: api.NewLiveClient(httpClient, hostname, logger), + Limit: 10, + Owner: baseRepo.RepoOwner(), + PredicateType: "https://in-toto.io/attestation/release/v0.1", + Logger: logger, + } + + option.HttpClient = httpClient + option.BaseRepo = baseRepo + option.IO = opts.IO + option.TagName = opts.TagName + option.Exporter = opts.Exporter + + td, err := option.APIClient.GetTrustDomain() + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to get trust domain")) + return err + } + + ec, err := attestation.NewEnforcementCriteria(&option, logger) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) + return err + } + + config := verification.SigstoreConfig{ + TrustedRoot: "", + Logger: logger, + NoPublicGood: true, + TrustDomain: td, + } + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + return err + } + + option.SigstoreVerifier = sigstoreVerifier + option.EC = ec + + // output ec + return verifyRun(&option) + }, + } + + cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.ReleaseFields) + + return cmd +} + +func verifyRun(opts *attestation.AttestOptions) error { + ctx := context.Background() + logger := opts.Logger + + release, err := shared.FetchRelease(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + if err != nil { + return err + } + + sha, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + if err != nil { + return err + } + + artifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") + + // Attestation fetching + attestations, logMsg, err := attestation.GetAttestations(opts, artifact.DigestWithAlg()) + if err != nil { + if errors.Is(err, api.ErrNoAttestationsFound) { + logger.Printf(logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg()) + return err + } + logger.Println(logger.ColorScheme.Red(logMsg)) + return err + } + + // Filter attestations by predicate PURL + filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, logger) + + // Verify attestations + verified, errMsg, err := attestation.VerifyAttestations(*artifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) + + if err != nil { + logger.Println(logger.ColorScheme.Red(errMsg)) + return err + } + + logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) + logger.Println(logger.ColorScheme.Green("✓ Verification succeeded!\n")) + + printVerifiedSubjects(verified, logger) + + opts.IO.DetectTerminalTheme() + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) + } + defer opts.IO.StopPager() + + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, release) + } + + if opts.IO.IsStdoutTTY() { + return renderVerifyTTY(opts.IO, release) + } + return renderVerifyPlain(opts.IO.Out, release) +} + +func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, logger *att_io.Handler) { + for _, att := range verified { + statement := att.Attestation.Bundle.GetDsseEnvelope().Payload + var statementData v1.Statement + err := protojson.Unmarshal([]byte(statement), &statementData) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + continue + } + for _, s := range statementData.Subject { + logger.Printf("%s\n", s.String()) + } + } +} + +func renderVerifyTTY(io *iostreams.IOStreams, release *shared.Release) error { + cs := io.ColorScheme() + w := io.Out + + fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) + if release.IsDraft { + fmt.Fprintf(w, "%s • ", cs.Red("Draft")) + } else if release.IsPrerelease { + fmt.Fprintf(w, "%s • ", cs.Yellow("Pre-release")) + } + if release.IsDraft { + fmt.Fprintln(w, cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))) + } else { + fmt.Fprintln(w, cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))) + } + + renderedDescription, err := markdown.Render(release.Body, + markdown.WithTheme(io.TerminalTheme()), + markdown.WithWrap(io.TerminalWidth())) + if err != nil { + return err + } + fmt.Fprintln(w, renderedDescription) + + if len(release.Assets) > 0 { + 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 { + table.AddField(a.Name) + table.AddField(humanFileSize(a.Size)) + table.EndRow() + } + err := table.Render() + if err != nil { + return err + } + fmt.Fprint(w, "\n") + } + + fmt.Fprintln(w, cs.Mutedf("View on GitHub: %s", release.URL)) + return nil +} + +func renderVerifyPlain(w io.Writer, release *shared.Release) error { + fmt.Fprintf(w, "title:\t%s\n", release.Name) + fmt.Fprintf(w, "tag:\t%s\n", release.TagName) + fmt.Fprintf(w, "draft:\t%v\n", release.IsDraft) + fmt.Fprintf(w, "prerelease:\t%v\n", release.IsPrerelease) + fmt.Fprintf(w, "author:\t%s\n", release.Author.Login) + fmt.Fprintf(w, "created:\t%s\n", release.CreatedAt.Format(time.RFC3339)) + if !release.IsDraft { + fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt.Format(time.RFC3339)) + } + fmt.Fprintf(w, "url:\t%s\n", release.URL) + for _, a := range release.Assets { + fmt.Fprintf(w, "asset:\t%s\n", a.Name) + } + fmt.Fprint(w, "--\n") + fmt.Fprint(w, release.Body) + if !strings.HasSuffix(release.Body, "\n") { + fmt.Fprintf(w, "\n") + } + return nil +} + +func humanFileSize(s int64) string { + if s < 1024 { + return fmt.Sprintf("%d B", s) + } + + kb := float64(s) / 1024 + if kb < 1024 { + return fmt.Sprintf("%s KiB", floatToString(kb, 2)) + } + + mb := kb / 1024 + if mb < 1024 { + return fmt.Sprintf("%s MiB", floatToString(mb, 2)) + } + + gb := mb / 1024 + return fmt.Sprintf("%s GiB", floatToString(gb, 2)) +} + +// render float to fixed precision using truncation instead of rounding +func floatToString(f float64, p uint8) string { + fs := fmt.Sprintf("%#f%0*s", f, p, "") + idx := strings.IndexRune(fs, '.') + return fs[:idx+int(p)+1] +} diff --git a/pkg/cmd/release/verify/options.go b/pkg/cmd/release/verify/options.go deleted file mode 100644 index e47c4f4a8..000000000 --- a/pkg/cmd/release/verify/options.go +++ /dev/null @@ -1,104 +0,0 @@ -package verify - -import ( - "fmt" - "path/filepath" - "strings" - - "github.com/cli/cli/v2/internal/gh" - "github.com/cli/cli/v2/internal/ghinstance" - "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" - "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmdutil" -) - -// Options captures the options for the verify command -type Options struct { - ArtifactPath string - BundlePath string - UseBundleFromRegistry bool - Config func() (gh.Config, error) - TrustedRoot string - DenySelfHostedRunner bool - DigestAlgorithm string - Limit int - NoPublicGood bool - OIDCIssuer string - Owner string - PredicateType string - Repo string - SAN string - SANRegex string - SignerDigest string - SignerRepo string - SignerWorkflow string - SourceDigest string - SourceRef string - APIClient api.Client - Logger *io.Handler - OCIClient oci.Client - SigstoreVerifier verification.SigstoreVerifier - exporter cmdutil.Exporter - Hostname string - // Tenant is only set when tenancy is used - Tenant string -} - -// Clean cleans the file path option values -func (opts *Options) Clean() { - if opts.BundlePath != "" { - opts.BundlePath = filepath.Clean(opts.BundlePath) - } -} - -// FetchAttestationsFromGitHubAPI returns true if the command should fetch attestations from the GitHub API -// It checks that a bundle path is not provided and that the "use bundle from registry" flag is not set -func (opts *Options) FetchAttestationsFromGitHubAPI() bool { - return opts.BundlePath == "" && !opts.UseBundleFromRegistry -} - -// AreFlagsValid checks that the provided flag combination is valid -// and returns an error otherwise -func (opts *Options) AreFlagsValid() error { - // If provided, check that the Repo option is in the expected format / - if opts.Repo != "" && !isProvidedRepoValid(opts.Repo) { - return fmt.Errorf("invalid value provided for repo: %s", opts.Repo) - } - - // If provided, check that the SignerRepo option is in the expected format / - if opts.SignerRepo != "" && !isProvidedRepoValid(opts.SignerRepo) { - return fmt.Errorf("invalid value provided for signer-repo: %s", opts.SignerRepo) - } - - // Check that limit is between 1 and 1000 - if opts.Limit < 1 || opts.Limit > 1000 { - return fmt.Errorf("limit %d not allowed, must be between 1 and 1000", opts.Limit) - } - - // Check that the bundle-from-oci flag is only used with OCI artifact paths - if opts.UseBundleFromRegistry && !strings.HasPrefix(opts.ArtifactPath, "oci://") { - return fmt.Errorf("bundle-from-oci flag can only be used with OCI artifact paths") - } - - // Check that both the bundle-from-oci and bundle-path flags are not used together - if opts.UseBundleFromRegistry && opts.BundlePath != "" { - return fmt.Errorf("bundle-from-oci flag cannot be used with bundle-path flag") - } - - // Verify provided hostname - if opts.Hostname != "" { - if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { - return fmt.Errorf("error parsing hostname: %w", err) - } - } - - return nil -} - -func isProvidedRepoValid(repo string) bool { - // we expect a provided repository argument be in the format / - splitRepo := strings.Split(repo, "/") - return len(splitRepo) == 2 -} diff --git a/pkg/cmd/release/verify/policy.go b/pkg/cmd/release/verify/policy.go deleted file mode 100644 index 1d1595eca..000000000 --- a/pkg/cmd/release/verify/policy.go +++ /dev/null @@ -1,168 +0,0 @@ -package verify - -import ( - "errors" - "fmt" - "regexp" - "strings" - - "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" - "github.com/sigstore/sigstore-go/pkg/verify" - - "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" -) - -const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` - -func expandToGitHubURL(tenant, ownerOrRepo string) string { - if tenant == "" { - return fmt.Sprintf("https://github.com/%s", ownerOrRepo) - } - return fmt.Sprintf("https://%s.ghe.com/%s", tenant, ownerOrRepo) -} - -func expandToGitHubURLRegex(tenant, ownerOrRepo string) string { - url := expandToGitHubURL(tenant, ownerOrRepo) - return fmt.Sprintf("(?i)^%s/", url) -} - -func newEnforcementCriteria(opts *Options) (verification.EnforcementCriteria, error) { - // initialize the enforcement criteria with the provided PredicateType - c := verification.EnforcementCriteria{ - PredicateType: opts.PredicateType, - } - - // set the owner value by checking the repo and owner options - var owner string - if opts.Repo != "" { - // we expect the repo argument to be in the format / - splitRepo := strings.Split(opts.Repo, "/") - // if Repo is provided but owner is not, set the OWNER portion of the Repo value - // to Owner - owner = splitRepo[0] - } else { - // otherwise use the user provided owner value - owner = opts.Owner - } - - // Set the SANRegex and SAN values using the provided options - // First check if the opts.SANRegex or opts.SAN values are provided - if opts.SANRegex != "" || opts.SAN != "" { - c.SANRegex = opts.SANRegex - c.SAN = opts.SAN - } else if opts.SignerRepo != "" { - // next check if opts.SignerRepo was provided - signedRepoRegex := expandToGitHubURLRegex(opts.Tenant, opts.SignerRepo) - c.SANRegex = signedRepoRegex - } else if opts.SignerWorkflow != "" { - validatedWorkflowRegex, err := validateSignerWorkflow(opts.Hostname, opts.SignerWorkflow) - if err != nil { - return verification.EnforcementCriteria{}, err - } - c.SANRegex = validatedWorkflowRegex - } else if opts.Repo != "" { - // if the user has not provided the SAN, SANRegex, SignerRepo, or SignerWorkflow options - // then we default to the repo option - c.SANRegex = expandToGitHubURLRegex(opts.Tenant, opts.Repo) - } else { - // if opts.Repo was not provided, we fall back to the opts.Owner value - c.SANRegex = expandToGitHubURLRegex(opts.Tenant, owner) - } - - // if the DenySelfHostedRunner option is set to true, set the - // RunnerEnvironment extension to the GitHub hosted runner value - if opts.DenySelfHostedRunner { - c.Certificate.RunnerEnvironment = verification.GitHubRunner - } else { - // if Certificate.RunnerEnvironment value is set to the empty string - // through the second function argument, - // no certificate matching will happen on the RunnerEnvironment field - c.Certificate.RunnerEnvironment = "" - } - - // If the Repo option is provided, set the SourceRepositoryURI extension - if opts.Repo != "" { - c.Certificate.SourceRepositoryURI = expandToGitHubURL(opts.Tenant, opts.Repo) - } - - // Set the SourceRepositoryOwnerURI extension using owner and tenant if provided - c.Certificate.SourceRepositoryOwnerURI = expandToGitHubURL(opts.Tenant, owner) - - // if the tenant is provided and OIDC issuer provided matches the default - // use the tenant-specific issuer - if opts.Tenant != "" && opts.OIDCIssuer == verification.GitHubOIDCIssuer { - c.Certificate.Issuer = fmt.Sprintf(verification.GitHubTenantOIDCIssuer, opts.Tenant) - } else { - // otherwise use the custom OIDC issuer provided as an option - c.Certificate.Issuer = opts.OIDCIssuer - } - - // 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 -} - -func buildCertificateIdentityOption(c verification.EnforcementCriteria) (verify.PolicyOption, error) { - sanMatcher, err := verify.NewSANMatcher(c.SAN, c.SANRegex) - if err != nil { - return nil, err - } - - // Accept any issuer, we will verify the issuer as part of the extension verification - issuerMatcher, err := verify.NewIssuerMatcher("", ".*") - if err != nil { - return nil, err - } - - extensions := certificate.Extensions{ - RunnerEnvironment: c.Certificate.RunnerEnvironment, - } - - certId, err := verify.NewCertificateIdentity(sanMatcher, issuerMatcher, extensions) - if err != nil { - return nil, err - } - - return verify.WithCertificateIdentity(certId), nil -} - -func buildSigstoreVerifyPolicy(c verification.EnforcementCriteria, a artifact.DigestedArtifact) (verify.PolicyBuilder, error) { - artifactDigestPolicyOption, err := verification.BuildDigestPolicyOption(a) - if err != nil { - return verify.PolicyBuilder{}, err - } - - certIdOption, err := buildCertificateIdentityOption(c) - if err != nil { - return verify.PolicyBuilder{}, err - } - - policy := verify.NewPolicy(artifactDigestPolicyOption, certIdOption) - return policy, nil -} - -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) - if err != nil { - return "", err - } - - if match { - return fmt.Sprintf("^https://%s", signerWorkflow), nil - } - - // if the provided workflow did not match the expect format - // we move onto creating a signer workflow using the provided host name - if hostname == "" { - return "", errors.New("unknown signer workflow host") - } - - return fmt.Sprintf("^https://%s/%s", hostname, signerWorkflow), nil -} diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index e26b1d50c..c739f226d 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -5,24 +5,22 @@ import ( "errors" "fmt" "io" - "net/http" "strings" "time" v1 "github.com/in-toto/attestation/go/v1" - - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "google.golang.org/protobuf/encoding/protojson" - "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" - att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmd/release/attestation" "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/markdown" @@ -30,17 +28,8 @@ import ( "github.com/spf13/cobra" ) -type VerifyOptions struct { - HttpClient func() (*http.Client, error) - IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) - Exporter cmdutil.Exporter - - TagName string -} - -func NewCmdVerify(f *cmdutil.Factory, runF func(*VerifyOptions) error) *cobra.Command { - opts := &VerifyOptions{ +func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) error) *cobra.Command { + opts := &attestation.VerifyOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, } @@ -55,6 +44,27 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*VerifyOptions) error) *cobra.Co is shown. `), Args: cobra.MaximumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + // Create a logger for use throughout the verify command + // opts.Logger = io.NewHandler(f.IOStreams) + + // // set the artifact path + // opts.ArtifactPath = args[0] + + // // Check that the given flag combination is valid + // if err := opts.AreFlagsValid(); err != nil { + // return err + // } + + // // Clean file path options + // opts.Clean() + + // if opts.TagName == "" { + // return cmdutil.FlagErrorf("tag name is required") + // } + + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo @@ -66,7 +76,63 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*VerifyOptions) error) *cobra.Co if runF != nil { return runF(opts) } - return verifyRun(opts) + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + logger := att_io.NewHandler(opts.IO) + hostname, _ := ghauth.DefaultHost() + option := attestation.AttestOptions{ + Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), + APIClient: api.NewLiveClient(httpClient, hostname, logger), + Limit: 10, + Owner: baseRepo.RepoOwner(), + PredicateType: "https://in-toto.io/attestation/release/v0.1", + Logger: logger, + } + + option.HttpClient = httpClient + option.BaseRepo = baseRepo + option.IO = opts.IO + option.TagName = opts.TagName + option.Exporter = opts.Exporter + + td, err := option.APIClient.GetTrustDomain() + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to get trust domain")) + return err + } + + ec, err := attestation.NewEnforcementCriteria(&option, logger) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) + return err + } + + config := verification.SigstoreConfig{ + TrustedRoot: "", + Logger: logger, + NoPublicGood: true, + TrustDomain: td, + } + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + return err + } + + option.SigstoreVerifier = sigstoreVerifier + option.EC = ec + + // output ec + return verifyRun(&option) }, } @@ -75,155 +141,48 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*VerifyOptions) error) *cobra.Co return cmd } -func verifyRun(opts *VerifyOptions) error { - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - - baseRepo, err := opts.BaseRepo() - if err != nil { - return err - } - +func verifyRun(opts *attestation.AttestOptions) error { ctx := context.Background() - var release *shared.Release + logger := opts.Logger - if opts.TagName == "" { - return cmdutil.FlagErrorf("tag name is required") - } else { - release, err = shared.FetchRelease(ctx, httpClient, baseRepo, opts.TagName) - if err != nil { - return err - } - } - - sha, err := shared.FetchRefSHA(ctx, httpClient, baseRepo, opts.TagName) + release, err := shared.FetchRelease(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) if err != nil { return err } + + sha, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + if err != nil { + return err + } + artifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") - sha = "sha1:" + sha - - // Resolved v1.0.0 to sha1:824acc86dd86a745b3014bd5353b844959f3591e - fmt.Println("Resolved", opts.TagName, "to "+sha) - - // Fetch Attestation - PredicateType := "https://in-toto.io/attestation/release/v0.1" - limit := 10 - - Hostname, _ := ghauth.DefaultHost() - - logger := att_io.NewHandler(opts.IO) - - repo := baseRepo.RepoOwner() + "/" + baseRepo.RepoName() - attestOption := &Options{ - Repo: repo, - APIClient: api.NewLiveClient(httpClient, Hostname, logger), - Limit: limit, - Owner: baseRepo.RepoOwner(), - PredicateType: PredicateType, - } - attestations, logMsg, err := getAttestations(attestOption, sha) - + // Attestation fetching + attestations, logMsg, err := attestation.GetAttestations(opts, artifact.DigestWithAlg()) if err != nil { - if ok := errors.Is(err, api.ErrNoAttestationsFound); ok { - logger.Printf(logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), sha) + if errors.Is(err, api.ErrNoAttestationsFound) { + logger.Printf(logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg()) return err } - // Print the message signifying failure fetching attestations logger.Println(logger.ColorScheme.Red(logMsg)) return err } - // Print the message signifying success fetching attestations - logger.Println(logMsg) - - td, err := attestOption.APIClient.GetTrustDomain() - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to get trust domain")) - return err - } - - // print information about the policy that will be enforced against attestations - // logger.Println("\nThe following policy criteria will be enforced:") - ec, err := newEnforcementCriteria(attestOption) - ec.SANRegex = "https://dotcom.releases.github.com" - - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) - return err - } - - config := verification.SigstoreConfig{ - TrustedRoot: "", - Logger: logger, - NoPublicGood: true, - } - - config.TrustDomain = td - - sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) - return err - } // Filter attestations by predicate PURL - var filteredAttestations []*api.Attestation - - for _, att := range attestations { - statement := att.Bundle.Bundle.GetDsseEnvelope().Payload - - var statementData v1.Statement - err = protojson.Unmarshal([]byte(statement), &statementData) - - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) - return err - } - expectedPURL := "pkg:github/" + attestOption.Repo + "@" + opts.TagName - purlValue := statementData.Predicate.GetFields()["purl"] - var purl string - if purlValue != nil { - purl = purlValue.GetStringValue() - } - - // fmt.Print("purlValue: ", expectedPURL, "\n") - // fmt.Print("purl: ", purl, "\n") - if purl == expectedPURL { - filteredAttestations = append(filteredAttestations, att) - } - } + filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, logger) // Verify attestations - verified, errMsg, err := verifyAttestations(*artifact, filteredAttestations, sigstoreVerifier, ec) + verified, errMsg, err := attestation.VerifyAttestations(*artifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) + if err != nil { logger.Println(logger.ColorScheme.Red(errMsg)) return err } logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) - logger.Println(logger.ColorScheme.Green("✓ Verification succeeded!\n")) - // Print verified attestations - for _, att := range verified { - statement := att.Attestation.Bundle.GetDsseEnvelope().Payload - - var statementData v1.Statement - err = protojson.Unmarshal([]byte(statement), &statementData) - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) - return err - } - - subjects := statementData.Subject - - for _, s := range subjects { - logger.Printf("%s\n", s.String()) - } - } + printVerifiedSubjects(verified, logger) opts.IO.DetectTerminalTheme() if err := opts.IO.StartPager(); err != nil { @@ -236,23 +195,31 @@ func verifyRun(opts *VerifyOptions) error { } if opts.IO.IsStdoutTTY() { - if err := renderVerifyTTY(opts.IO, release); err != nil { - return err + return renderVerifyTTY(opts.IO, release) + } + return renderVerifyPlain(opts.IO.Out, release) +} + +func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, logger *att_io.Handler) { + for _, att := range verified { + statement := att.Attestation.Bundle.GetDsseEnvelope().Payload + var statementData v1.Statement + err := protojson.Unmarshal([]byte(statement), &statementData) + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + continue } - } else { - if err := renderVerifyPlain(opts.IO.Out, release); err != nil { - return err + for _, s := range statementData.Subject { + logger.Printf("%s\n", s.String()) } } - - return nil } func renderVerifyTTY(io *iostreams.IOStreams, release *shared.Release) error { cs := io.ColorScheme() w := io.Out - // fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) + fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) if release.IsDraft { fmt.Fprintf(w, "%s • ", cs.Red("Draft")) } else if release.IsPrerelease { From 3e5456827c0f2fe86442897665bb4a580b7f9d18 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Tue, 20 May 2025 18:02:13 -0400 Subject: [PATCH 170/249] update the lng --- pkg/cmd/release/attestation/attestation.go | 26 ++ pkg/cmd/release/attestation/options.go | 14 +- pkg/cmd/release/attestation/policy.go | 8 - pkg/cmd/release/release.go | 3 + pkg/cmd/release/verify-asset/verify-asset.go | 220 ++++----------- pkg/cmd/release/verify/verify.go | 273 +++++-------------- 6 files changed, 160 insertions(+), 384 deletions(-) diff --git a/pkg/cmd/release/attestation/attestation.go b/pkg/cmd/release/attestation/attestation.go index a8c654f46..70760f8c6 100644 --- a/pkg/cmd/release/attestation/attestation.go +++ b/pkg/cmd/release/attestation/attestation.go @@ -83,3 +83,29 @@ func FilterAttestationsByPURL(attestations []*api.Attestation, repo, tagName str } return filtered } + +func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagName, fileDigest string, logger *att_io.Handler) []*api.Attestation { + var filtered []*api.Attestation + for _, att := range attestations { + statement := att.Bundle.Bundle.GetDsseEnvelope().Payload + var statementData v1.Statement + err := protojson.Unmarshal([]byte(statement), &statementData) + + if err != nil { + logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + continue + } + subjects := statementData.Subject + for _, subject := range subjects { + digestMap := subject.GetDigest() + alg := "sha256" + + digest := digestMap[alg] + if digest == fileDigest { + filtered = append(filtered, att) + } + } + + } + return filtered +} diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/attestation/options.go index c567ff7e6..d4b3046ae 100644 --- a/pkg/cmd/release/attestation/options.go +++ b/pkg/cmd/release/attestation/options.go @@ -16,6 +16,17 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" ) +type VerifyAssetOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + + BaseRepo func() (ghrepo.Interface, error) + Exporter cmdutil.Exporter + + TagName string + FilePath string +} + type VerifyOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams @@ -54,7 +65,8 @@ type AttestOptions struct { Hostname string EC verification.EnforcementCriteria // Tenant is only set when tenancy is used - Tenant string + Tenant string + FilePath string } // AreFlagsValid checks that the provided flag combination is valid diff --git a/pkg/cmd/release/attestation/policy.go b/pkg/cmd/release/attestation/policy.go index f875acf08..7dfb88cfe 100644 --- a/pkg/cmd/release/attestation/policy.go +++ b/pkg/cmd/release/attestation/policy.go @@ -11,8 +11,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/verification" ) -const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$` - func expandToGitHubURL(tenant, ownerOrRepo string) string { if tenant == "" { return fmt.Sprintf("https://github.com/%s", ownerOrRepo) @@ -20,12 +18,6 @@ func expandToGitHubURL(tenant, ownerOrRepo string) string { return fmt.Sprintf("https://%s.ghe.com/%s", tenant, ownerOrRepo) } -// TODO: revisit this policy -func expandToGitHubURLRegex(tenant, ownerOrRepo string) string { - url := expandToGitHubURL(tenant, ownerOrRepo) - return fmt.Sprintf("(?i)^%s/", url) -} - // TODO: revist this policy func NewEnforcementCriteria(opts *AttestOptions, logger *att_io.Handler) (verification.EnforcementCriteria, error) { // initialize the enforcement criteria with the provided PredicateType and SAN diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index 3e40b03e7..f25e8bd3a 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -9,6 +9,8 @@ import ( cmdList "github.com/cli/cli/v2/pkg/cmd/release/list" cmdUpload "github.com/cli/cli/v2/pkg/cmd/release/upload" cmdVerify "github.com/cli/cli/v2/pkg/cmd/release/verify" + cmdVerifyAsset "github.com/cli/cli/v2/pkg/cmd/release/verify-asset" + cmdView "github.com/cli/cli/v2/pkg/cmd/release/view" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -36,6 +38,7 @@ func NewCmdRelease(f *cmdutil.Factory) *cobra.Command { cmdDelete.NewCmdDelete(f, nil), cmdDeleteAsset.NewCmdDeleteAsset(f, nil), cmdVerify.NewCmdVerify(f, nil), + cmdVerifyAsset.NewCmdVerifyAsset(f, nil), ) return cmd diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 3b7d1e8e9..8df2f2a11 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -1,18 +1,12 @@ -package verify_asset +package verifyasset import ( "context" "errors" - "fmt" - "io" - "strings" - "time" + "path/filepath" - v1 "github.com/in-toto/attestation/go/v1" - "google.golang.org/protobuf/encoding/protojson" + ghauth "github.com/cli/go-gh/v2/pkg/auth" - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" @@ -22,47 +16,20 @@ import ( "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/markdown" - ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/spf13/cobra" ) -func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) error) *cobra.Command { - opts := &attestation.VerifyOptions{ +func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.VerifyAssetOptions) error) *cobra.Command { + opts := &attestation.VerifyAssetOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, } cmd := &cobra.Command{ - Use: "verify-asset []", - Short: "Verify information about a release", - Long: heredoc.Doc(` - Verify information about a GitHub Release. - - Without an explicit tag name argument, the latest release in the project - is shown. - `), - Args: cobra.ExactArgs(1), + Use: "verify-asset ", + Short: "Verify that a given asset originated from a specific GitHub Release.", + Args: cobra.ExactArgs(2), PreRunE: func(cmd *cobra.Command, args []string) error { - // Create a logger for use throughout the verify command - // opts.Logger = io.NewHandler(f.IOStreams) - - // // set the artifact path - // opts.ArtifactPath = args[0] - - // // Check that the given flag combination is valid - // if err := opts.AreFlagsValid(); err != nil { - // return err - // } - - // // Clean file path options - // opts.Clean() - - // if opts.TagName == "" { - // return cmdutil.FlagErrorf("tag name is required") - // } - return nil }, RunE: func(cmd *cobra.Command, args []string) error { @@ -72,6 +39,9 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) erro if len(args) > 0 { opts.TagName = args[0] } + if len(args) > 1 { + opts.FilePath = args[1] + } if runF != nil { return runF(opts) @@ -103,6 +73,7 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) erro option.IO = opts.IO option.TagName = opts.TagName option.Exporter = opts.Exporter + option.FilePath = opts.FilePath td, err := option.APIClient.GetTrustDomain() if err != nil { @@ -132,177 +103,78 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) erro option.EC = ec // output ec - return verifyRun(&option) + return verifyAssetRun(&option) }, } cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.ReleaseFields) - return cmd } -func verifyRun(opts *attestation.AttestOptions) error { +func verifyAssetRun(opts *attestation.AttestOptions) error { ctx := context.Background() - logger := opts.Logger + fileName := getFileName(opts.FilePath) - release, err := shared.FetchRelease(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + // calculate the digest of the file + fileDigest, err := artifact.NewDigestedArtifact(nil, opts.FilePath, "sha256") if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to calculate file digest")) return err } + opts.Logger.Printf("Loaded digest %s for %s\n", fileDigest.DigestWithAlg(), fileName) + sha, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) if err != nil { return err } - - artifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") + releaseArtifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") + opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseArtifact.DigestWithAlg()) // Attestation fetching - attestations, logMsg, err := attestation.GetAttestations(opts, artifact.DigestWithAlg()) + attestations, logMsg, err := attestation.GetAttestations(opts, releaseArtifact.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { - logger.Printf(logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseArtifact.DigestWithAlg()) return err } - logger.Println(logger.ColorScheme.Red(logMsg)) + opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) return err } // Filter attestations by predicate PURL - filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, logger) + filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, opts.Logger) + filteredAttestations = attestation.FilterAttestationsByFileDigest(filteredAttestations, opts.Repo, opts.TagName, fileDigest.Digest(), opts.Logger) + + opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations - verified, errMsg, err := attestation.VerifyAttestations(*artifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) + verified, errMsg, err := attestation.VerifyAttestations(*releaseArtifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) if err != nil { - logger.Println(logger.ColorScheme.Red(errMsg)) + opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) + + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Verification failed")) + + // Release v1.0.0 does not contain bin-linux.tgz (sha256:0c2524c2b002fda89f8b766c7d3dd8e6ac1de183556728a83182c6137f19643d) + + opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.FilePath, fileDigest.DigestWithAlg()) return err } - logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) - logger.Println(logger.ColorScheme.Green("✓ Verification succeeded!\n")) + opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) + opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) - printVerifiedSubjects(verified, logger) + opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseArtifact.DigestWithAlg()) - opts.IO.DetectTerminalTheme() - if err := opts.IO.StartPager(); err != nil { - fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) - } - defer opts.IO.StopPager() + // bin-linux.tgz is present in release v1.0.0 + opts.Logger.Printf("%s is present in release %s\n", fileName, opts.TagName) - if opts.Exporter != nil { - return opts.Exporter.Write(opts.IO, release) - } - - if opts.IO.IsStdoutTTY() { - return renderVerifyTTY(opts.IO, release) - } - return renderVerifyPlain(opts.IO.Out, release) -} - -func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, logger *att_io.Handler) { - for _, att := range verified { - statement := att.Attestation.Bundle.GetDsseEnvelope().Payload - var statementData v1.Statement - err := protojson.Unmarshal([]byte(statement), &statementData) - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) - continue - } - for _, s := range statementData.Subject { - logger.Printf("%s\n", s.String()) - } - } -} - -func renderVerifyTTY(io *iostreams.IOStreams, release *shared.Release) error { - cs := io.ColorScheme() - w := io.Out - - fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) - if release.IsDraft { - fmt.Fprintf(w, "%s • ", cs.Red("Draft")) - } else if release.IsPrerelease { - fmt.Fprintf(w, "%s • ", cs.Yellow("Pre-release")) - } - if release.IsDraft { - fmt.Fprintln(w, cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))) - } else { - fmt.Fprintln(w, cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))) - } - - renderedDescription, err := markdown.Render(release.Body, - markdown.WithTheme(io.TerminalTheme()), - markdown.WithWrap(io.TerminalWidth())) - if err != nil { - return err - } - fmt.Fprintln(w, renderedDescription) - - if len(release.Assets) > 0 { - 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 { - table.AddField(a.Name) - table.AddField(humanFileSize(a.Size)) - table.EndRow() - } - err := table.Render() - if err != nil { - return err - } - fmt.Fprint(w, "\n") - } - - fmt.Fprintln(w, cs.Mutedf("View on GitHub: %s", release.URL)) return nil } -func renderVerifyPlain(w io.Writer, release *shared.Release) error { - fmt.Fprintf(w, "title:\t%s\n", release.Name) - fmt.Fprintf(w, "tag:\t%s\n", release.TagName) - fmt.Fprintf(w, "draft:\t%v\n", release.IsDraft) - fmt.Fprintf(w, "prerelease:\t%v\n", release.IsPrerelease) - fmt.Fprintf(w, "author:\t%s\n", release.Author.Login) - fmt.Fprintf(w, "created:\t%s\n", release.CreatedAt.Format(time.RFC3339)) - if !release.IsDraft { - fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt.Format(time.RFC3339)) - } - fmt.Fprintf(w, "url:\t%s\n", release.URL) - for _, a := range release.Assets { - fmt.Fprintf(w, "asset:\t%s\n", a.Name) - } - fmt.Fprint(w, "--\n") - fmt.Fprint(w, release.Body) - if !strings.HasSuffix(release.Body, "\n") { - fmt.Fprintf(w, "\n") - } - return nil -} - -func humanFileSize(s int64) string { - if s < 1024 { - return fmt.Sprintf("%d B", s) - } - - kb := float64(s) / 1024 - if kb < 1024 { - return fmt.Sprintf("%s KiB", floatToString(kb, 2)) - } - - mb := kb / 1024 - if mb < 1024 { - return fmt.Sprintf("%s MiB", floatToString(mb, 2)) - } - - gb := mb / 1024 - return fmt.Sprintf("%s GiB", floatToString(gb, 2)) -} - -// render float to fixed precision using truncation instead of rounding -func floatToString(f float64, p uint8) string { - fs := fmt.Sprintf("%#f%0*s", f, p, "") - idx := strings.IndexRune(fs, '.') - return fs[:idx+int(p)+1] +func getFileName(filePath string) string { + // Get the file name from the file path + _, fileName := filepath.Split(filePath) + return fileName } diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index c739f226d..4232cfca1 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -3,16 +3,10 @@ package verify import ( "context" "errors" - "fmt" - "io" - "strings" - "time" v1 "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/encoding/protojson" - "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" @@ -22,117 +16,84 @@ import ( "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/cli/cli/v2/pkg/markdown" ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/spf13/cobra" ) -func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) error) *cobra.Command { - opts := &attestation.VerifyOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - } +func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) error) *cobra.Command { + opts := &attestation.AttestOptions{} cmd := &cobra.Command{ Use: "verify []", - Short: "Verify information about a release", - Long: heredoc.Doc(` - Verify information about a GitHub Release. - - Without an explicit tag name argument, the latest release in the project - is shown. - `), - Args: cobra.MaximumNArgs(1), + Short: "Verify the attestation for a GitHub Release.", + Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - // Create a logger for use throughout the verify command - // opts.Logger = io.NewHandler(f.IOStreams) - - // // set the artifact path - // opts.ArtifactPath = args[0] - - // // Check that the given flag combination is valid - // if err := opts.AreFlagsValid(); err != nil { - // return err - // } - - // // Clean file path options - // opts.Clean() - - // if opts.TagName == "" { - // return cmdutil.FlagErrorf("tag name is required") - // } - - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo - if len(args) > 0 { opts.TagName = args[0] } + httpClient, err := f.HttpClient() + if err != nil { + return err + } + + baseRepo, err := f.BaseRepo() + if err != nil { + return err + } + + logger := att_io.NewHandler(f.IOStreams) + hostname, _ := ghauth.DefaultHost() + + opts.Repo = baseRepo.RepoOwner() + "/" + baseRepo.RepoName() + opts.APIClient = api.NewLiveClient(httpClient, hostname, logger) + opts.Limit = 10 + opts.Owner = baseRepo.RepoOwner() + opts.PredicateType = "https://in-toto.io/attestation/release/v0.1" + opts.Logger = logger + + opts.HttpClient = httpClient + opts.BaseRepo = baseRepo + + opts.HttpClient = httpClient + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { if runF != nil { return runF(opts) } + // - httpClient, err := opts.HttpClient() + td, err := opts.APIClient.GetTrustDomain() if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) return err } - baseRepo, err := opts.BaseRepo() + ec, err := attestation.NewEnforcementCriteria(opts, opts.Logger) if err != nil { - return err - } - - logger := att_io.NewHandler(opts.IO) - hostname, _ := ghauth.DefaultHost() - option := attestation.AttestOptions{ - Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), - APIClient: api.NewLiveClient(httpClient, hostname, logger), - Limit: 10, - Owner: baseRepo.RepoOwner(), - PredicateType: "https://in-toto.io/attestation/release/v0.1", - Logger: logger, - } - - option.HttpClient = httpClient - option.BaseRepo = baseRepo - option.IO = opts.IO - option.TagName = opts.TagName - option.Exporter = opts.Exporter - - td, err := option.APIClient.GetTrustDomain() - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to get trust domain")) - return err - } - - ec, err := attestation.NewEnforcementCriteria(&option, logger) - if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err } config := verification.SigstoreConfig{ TrustedRoot: "", - Logger: logger, + Logger: opts.Logger, NoPublicGood: true, TrustDomain: td, } sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) return err } - option.SigstoreVerifier = sigstoreVerifier - option.EC = ec + opts.SigstoreVerifier = sigstoreVerifier + opts.EC = ec // output ec - return verifyRun(&option) + return verifyRun(opts) }, } @@ -143,12 +104,6 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.VerifyOptions) erro func verifyRun(opts *attestation.AttestOptions) error { ctx := context.Background() - logger := opts.Logger - - release, err := shared.FetchRelease(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) - if err != nil { - return err - } sha, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) if err != nil { @@ -156,48 +111,43 @@ func verifyRun(opts *attestation.AttestOptions) error { } artifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") + opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, artifact.DigestWithAlg()) // Attestation fetching attestations, logMsg, err := attestation.GetAttestations(opts, artifact.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { - logger.Printf(logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg()) return err } - logger.Println(logger.ColorScheme.Red(logMsg)) + opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) return err } // Filter attestations by predicate PURL - filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, logger) + filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, opts.Logger) + + opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations verified, errMsg, err := attestation.VerifyAttestations(*artifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) if err != nil { - logger.Println(logger.ColorScheme.Red(errMsg)) + opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) + + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Verification failed")) + + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ Failed to find an attestation for release %s in %s\n"), opts.TagName, opts.Repo) return err } - logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) - logger.Println(logger.ColorScheme.Green("✓ Verification succeeded!\n")) + opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) + opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) - printVerifiedSubjects(verified, logger) + opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, artifact.Digest()) + printVerifiedSubjects(verified, opts.Logger) - opts.IO.DetectTerminalTheme() - if err := opts.IO.StartPager(); err != nil { - fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err) - } - defer opts.IO.StopPager() - - if opts.Exporter != nil { - return opts.Exporter.Write(opts.IO, release) - } - - if opts.IO.IsStdoutTTY() { - return renderVerifyTTY(opts.IO, release) - } - return renderVerifyPlain(opts.IO.Out, release) + return nil } func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, logger *att_io.Handler) { @@ -210,99 +160,20 @@ func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, continue } for _, s := range statementData.Subject { - logger.Printf("%s\n", s.String()) + name := s.Name + digest := s.Digest + + if name != "" { + // digest is map[string]string and i want to be key:value + // so i need to iterate over the map and print key:value + digestStr := "" + for key, value := range digest { + digestStr += key + ":" + value + } + // output should like this + // bin-linux.tgz sha256:0c2524c2b002fda89f8b766c7d3dd8e6ac1de183556728a83182c6137f19643d + logger.Println(" " + name + " " + digestStr) + } } } } - -func renderVerifyTTY(io *iostreams.IOStreams, release *shared.Release) error { - cs := io.ColorScheme() - w := io.Out - - fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName)) - if release.IsDraft { - fmt.Fprintf(w, "%s • ", cs.Red("Draft")) - } else if release.IsPrerelease { - fmt.Fprintf(w, "%s • ", cs.Yellow("Pre-release")) - } - if release.IsDraft { - fmt.Fprintln(w, cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))) - } else { - fmt.Fprintln(w, cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))) - } - - renderedDescription, err := markdown.Render(release.Body, - markdown.WithTheme(io.TerminalTheme()), - markdown.WithWrap(io.TerminalWidth())) - if err != nil { - return err - } - fmt.Fprintln(w, renderedDescription) - - if len(release.Assets) > 0 { - 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 { - table.AddField(a.Name) - table.AddField(humanFileSize(a.Size)) - table.EndRow() - } - err := table.Render() - if err != nil { - return err - } - fmt.Fprint(w, "\n") - } - - fmt.Fprintln(w, cs.Mutedf("View on GitHub: %s", release.URL)) - return nil -} - -func renderVerifyPlain(w io.Writer, release *shared.Release) error { - fmt.Fprintf(w, "title:\t%s\n", release.Name) - fmt.Fprintf(w, "tag:\t%s\n", release.TagName) - fmt.Fprintf(w, "draft:\t%v\n", release.IsDraft) - fmt.Fprintf(w, "prerelease:\t%v\n", release.IsPrerelease) - fmt.Fprintf(w, "author:\t%s\n", release.Author.Login) - fmt.Fprintf(w, "created:\t%s\n", release.CreatedAt.Format(time.RFC3339)) - if !release.IsDraft { - fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt.Format(time.RFC3339)) - } - fmt.Fprintf(w, "url:\t%s\n", release.URL) - for _, a := range release.Assets { - fmt.Fprintf(w, "asset:\t%s\n", a.Name) - } - fmt.Fprint(w, "--\n") - fmt.Fprint(w, release.Body) - if !strings.HasSuffix(release.Body, "\n") { - fmt.Fprintf(w, "\n") - } - return nil -} - -func humanFileSize(s int64) string { - if s < 1024 { - return fmt.Sprintf("%d B", s) - } - - kb := float64(s) / 1024 - if kb < 1024 { - return fmt.Sprintf("%s KiB", floatToString(kb, 2)) - } - - mb := kb / 1024 - if mb < 1024 { - return fmt.Sprintf("%s MiB", floatToString(mb, 2)) - } - - gb := mb / 1024 - return fmt.Sprintf("%s GiB", floatToString(gb, 2)) -} - -// render float to fixed precision using truncation instead of rounding -func floatToString(f float64, p uint8) string { - fs := fmt.Sprintf("%#f%0*s", f, p, "") - idx := strings.IndexRune(fs, '.') - return fs[:idx+int(p)+1] -} From 0a6ce2bb74b54fb7779deeb84c729afac2c9cc64 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Tue, 20 May 2025 18:35:40 -0400 Subject: [PATCH 171/249] clean up the code --- pkg/cmd/attestation/artifact/artifact.go | 3 +- pkg/cmd/attestation/verification/sigstore.go | 3 - pkg/cmd/release/attestation/attestation.go | 25 ++-- pkg/cmd/release/verify-asset/verify-asset.go | 117 +++++++++---------- pkg/cmd/release/verify/verify.go | 63 +++++----- 5 files changed, 93 insertions(+), 118 deletions(-) diff --git a/pkg/cmd/attestation/artifact/artifact.go b/pkg/cmd/attestation/artifact/artifact.go index 53f8d8aad..9d8125450 100644 --- a/pkg/cmd/attestation/artifact/artifact.go +++ b/pkg/cmd/attestation/artifact/artifact.go @@ -54,9 +54,8 @@ func normalizeReference(reference string, pathSeparator rune) (normalized string return filepath.Clean(reference), fileArtifactType, nil } -func NewDigestedArtifactForRelease(URL string, digest string, digestAlg string) (artifact *DigestedArtifact) { +func NewDigestedArtifactForRelease(digest string, digestAlg string) (artifact *DigestedArtifact) { return &DigestedArtifact{ - URL: URL, digest: digest, digestAlg: digestAlg, } diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 14c8875d9..190ea5c0f 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -239,9 +239,6 @@ func (v *LiveSigstoreVerifier) verify(attestation *api.Attestation, policy verif result, err := verifier.Verify(attestation.Bundle, policy) // if verification fails, create the error and exit verification early if err != nil { - v.Logger.VerbosePrint(v.Logger.ColorScheme.Redf( - "Error is \"%s\"\n", err.Error(), - )) v.Logger.VerbosePrint(v.Logger.ColorScheme.Redf( "Failed to verify against issuer \"%s\" \n\n", issuer, )) diff --git a/pkg/cmd/release/attestation/attestation.go b/pkg/cmd/release/attestation/attestation.go index 70760f8c6..08e1398b8 100644 --- a/pkg/cmd/release/attestation/attestation.go +++ b/pkg/cmd/release/attestation/attestation.go @@ -9,7 +9,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" v1 "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/encoding/protojson" ) @@ -61,30 +60,25 @@ func VerifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, s return sigstoreVerified, "", nil } -func FilterAttestationsByPURL(attestations []*api.Attestation, repo, tagName string, logger *att_io.Handler) []*api.Attestation { +func FilterAttestationsByTag(attestations []*api.Attestation, tagName string) ([]*api.Attestation, error) { var filtered []*api.Attestation - expectedPURL := "pkg:github/" + repo + "@" + tagName for _, att := range attestations { statement := att.Bundle.Bundle.GetDsseEnvelope().Payload var statementData v1.Statement err := protojson.Unmarshal([]byte(statement), &statementData) if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) - continue + return nil, fmt.Errorf("failed to unmarshal statement: %w", err) } - purlValue := statementData.Predicate.GetFields()["purl"] - var purl string - if purlValue != nil { - purl = purlValue.GetStringValue() - } - if purl == expectedPURL { + tagValue := statementData.Predicate.GetFields()["tag"].GetStringValue() + + if tagValue == tagName { filtered = append(filtered, att) } } - return filtered + return filtered, nil } -func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagName, fileDigest string, logger *att_io.Handler) []*api.Attestation { +func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagName, fileDigest string) ([]*api.Attestation, error) { var filtered []*api.Attestation for _, att := range attestations { statement := att.Bundle.Bundle.GetDsseEnvelope().Payload @@ -92,8 +86,7 @@ func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagNa err := protojson.Unmarshal([]byte(statement), &statementData) if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) - continue + return nil, fmt.Errorf("failed to unmarshal statement: %w", err) } subjects := statementData.Subject for _, subject := range subjects { @@ -107,5 +100,5 @@ func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagNa } } - return filtered + return filtered, nil } diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 8df2f2a11..666ad3f45 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -19,91 +19,76 @@ import ( "github.com/spf13/cobra" ) -func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.VerifyAssetOptions) error) *cobra.Command { - opts := &attestation.VerifyAssetOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - } +func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) error) *cobra.Command { + opts := &attestation.AttestOptions{} cmd := &cobra.Command{ Use: "verify-asset ", Short: "Verify that a given asset originated from a specific GitHub Release.", Args: cobra.ExactArgs(2), PreRunE: func(cmd *cobra.Command, args []string) error { - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - // support `-R, --repo` override - opts.BaseRepo = f.BaseRepo + opts.TagName = args[0] + opts.FilePath = args[1] - if len(args) > 0 { - opts.TagName = args[0] - } - if len(args) > 1 { - opts.FilePath = args[1] - } - - if runF != nil { - return runF(opts) - } - - httpClient, err := opts.HttpClient() + httpClient, err := f.HttpClient() if err != nil { return err } - - baseRepo, err := opts.BaseRepo() + baseRepo, err := f.BaseRepo() if err != nil { return err } - - logger := att_io.NewHandler(opts.IO) + logger := att_io.NewHandler(f.IOStreams) hostname, _ := ghauth.DefaultHost() - option := attestation.AttestOptions{ + + *opts = attestation.AttestOptions{ + TagName: opts.TagName, + FilePath: opts.FilePath, Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, Owner: baseRepo.RepoOwner(), PredicateType: "https://in-toto.io/attestation/release/v0.1", Logger: logger, + HttpClient: httpClient, + BaseRepo: baseRepo, + IO: f.IOStreams, + Exporter: opts.Exporter, + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) } - option.HttpClient = httpClient - option.BaseRepo = baseRepo - option.IO = opts.IO - option.TagName = opts.TagName - option.Exporter = opts.Exporter - option.FilePath = opts.FilePath - - td, err := option.APIClient.GetTrustDomain() + td, err := opts.APIClient.GetTrustDomain() if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to get trust domain")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) return err } - ec, err := attestation.NewEnforcementCriteria(&option, logger) + ec, err := attestation.NewEnforcementCriteria(opts, opts.Logger) if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to build policy information")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err } config := verification.SigstoreConfig{ - TrustedRoot: "", - Logger: logger, + Logger: opts.Logger, NoPublicGood: true, TrustDomain: td, } sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) return err } - option.SigstoreVerifier = sigstoreVerifier - option.EC = ec + opts.SigstoreVerifier = sigstoreVerifier + opts.EC = ec - // output ec - return verifyAssetRun(&option) + return verifyAssetRun(opts) }, } @@ -124,50 +109,56 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { opts.Logger.Printf("Loaded digest %s for %s\n", fileDigest.DigestWithAlg(), fileName) - sha, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + ref, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) if err != nil { return err } - releaseArtifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") - opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseArtifact.DigestWithAlg()) + releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") + opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) // Attestation fetching - attestations, logMsg, err := attestation.GetAttestations(opts, releaseArtifact.DigestWithAlg()) + attestations, logMsg, err := attestation.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseArtifact.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) return err } opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) return err } - // Filter attestations by predicate PURL - filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, opts.Logger) - filteredAttestations = attestation.FilterAttestationsByFileDigest(filteredAttestations, opts.Repo, opts.TagName, fileDigest.Digest(), opts.Logger) + // Filter attestations by tag + filteredAttestations, err := attestation.FilterAttestationsByTag(attestations, opts.TagName) + if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) + return err + } + + filteredAttestations, err = attestation.FilterAttestationsByFileDigest(filteredAttestations, opts.Repo, opts.TagName, fileDigest.Digest()) + if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) + return err + } + + if len(filteredAttestations) == 0 { + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for %s\n"), fileName) + return nil + } opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations - verified, errMsg, err := attestation.VerifyAttestations(*releaseArtifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) + verified, errMsg, err := attestation.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Verification failed")) - - // Release v1.0.0 does not contain bin-linux.tgz (sha256:0c2524c2b002fda89f8b766c7d3dd8e6ac1de183556728a83182c6137f19643d) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.FilePath, fileDigest.DigestWithAlg()) return err } opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) - - opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseArtifact.DigestWithAlg()) - - // bin-linux.tgz is present in release v1.0.0 + opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseRefDigest.DigestWithAlg()) opts.Logger.Printf("%s is present in release %s\n", fileName, opts.TagName) return nil diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 4232cfca1..149125dc6 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -28,10 +28,12 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro Short: "Verify the attestation for a GitHub Release.", Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - opts.TagName = args[0] + if len(args) < 1 { + return cmdutil.FlagErrorf("You must specify a tag") } + opts.TagName = args[0] + httpClient, err := f.HttpClient() if err != nil { return err @@ -41,29 +43,26 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro if err != nil { return err } - logger := att_io.NewHandler(f.IOStreams) hostname, _ := ghauth.DefaultHost() - opts.Repo = baseRepo.RepoOwner() + "/" + baseRepo.RepoName() - opts.APIClient = api.NewLiveClient(httpClient, hostname, logger) - opts.Limit = 10 - opts.Owner = baseRepo.RepoOwner() - opts.PredicateType = "https://in-toto.io/attestation/release/v0.1" - opts.Logger = logger - - opts.HttpClient = httpClient - opts.BaseRepo = baseRepo - - opts.HttpClient = httpClient - + *opts = attestation.AttestOptions{ + TagName: opts.TagName, + Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), + APIClient: api.NewLiveClient(httpClient, hostname, logger), + Limit: 10, + Owner: baseRepo.RepoOwner(), + PredicateType: "https://in-toto.io/attestation/release/v0.1", + Logger: logger, + HttpClient: httpClient, + BaseRepo: baseRepo, + } return nil }, RunE: func(cmd *cobra.Command, args []string) error { if runF != nil { return runF(opts) } - // td, err := opts.APIClient.GetTrustDomain() if err != nil { @@ -78,11 +77,11 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro } config := verification.SigstoreConfig{ - TrustedRoot: "", Logger: opts.Logger, NoPublicGood: true, TrustDomain: td, } + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) @@ -92,7 +91,6 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro opts.SigstoreVerifier = sigstoreVerifier opts.EC = ec - // output ec return verifyRun(opts) }, } @@ -105,38 +103,39 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro func verifyRun(opts *attestation.AttestOptions) error { ctx := context.Background() - sha, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + ref, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) if err != nil { return err } - artifact := artifact.NewDigestedArtifactForRelease(opts.TagName, sha, "sha1") - opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, artifact.DigestWithAlg()) + releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") + opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) // Attestation fetching - attestations, logMsg, err := attestation.GetAttestations(opts, artifact.DigestWithAlg()) + attestations, logMsg, err := attestation.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) return err } opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) return err } - // Filter attestations by predicate PURL - filteredAttestations := attestation.FilterAttestationsByPURL(attestations, opts.Repo, opts.TagName, opts.Logger) + // Filter attestations by predicate tag + filteredAttestations, err := attestation.FilterAttestationsByTag(attestations, opts.TagName) + if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) + return err + } opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations - verified, errMsg, err := attestation.VerifyAttestations(*artifact, filteredAttestations, opts.SigstoreVerifier, opts.EC) + verified, errMsg, err := attestation.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Verification failed")) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ Failed to find an attestation for release %s in %s\n"), opts.TagName, opts.Repo) return err } @@ -144,7 +143,7 @@ func verifyRun(opts *attestation.AttestOptions) error { opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) - opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, artifact.Digest()) + opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseRefDigest.Digest()) printVerifiedSubjects(verified, opts.Logger) return nil @@ -164,14 +163,10 @@ func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, digest := s.Digest if name != "" { - // digest is map[string]string and i want to be key:value - // so i need to iterate over the map and print key:value digestStr := "" for key, value := range digest { digestStr += key + ":" + value } - // output should like this - // bin-linux.tgz sha256:0c2524c2b002fda89f8b766c7d3dd8e6ac1de183556728a83182c6137f19643d logger.Println(" " + name + " " + digestStr) } } From 7a7c7d6605520f8558002448394a81e3435318ad Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Tue, 20 May 2025 18:44:04 -0400 Subject: [PATCH 172/249] minor fix --- pkg/cmd/release/attestation/options.go | 20 +------------------- pkg/cmd/release/verify-asset/verify-asset.go | 10 +++++----- pkg/cmd/release/verify/verify.go | 5 +---- 3 files changed, 7 insertions(+), 28 deletions(-) diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/attestation/options.go index d4b3046ae..aadbc2f47 100644 --- a/pkg/cmd/release/attestation/options.go +++ b/pkg/cmd/release/attestation/options.go @@ -16,24 +16,7 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" ) -type VerifyAssetOptions struct { - IO *iostreams.IOStreams - HttpClient func() (*http.Client, error) - - BaseRepo func() (ghrepo.Interface, error) - Exporter cmdutil.Exporter - - TagName string - FilePath string -} - -type VerifyOptions struct { - HttpClient func() (*http.Client, error) - IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) - Exporter cmdutil.Exporter - TagName string -} +const ReleasePredicateType = "https://in-toto.io/attestation/release/v0.1" // AttestOptions captures the options for the verify command type AttestOptions struct { @@ -61,7 +44,6 @@ type AttestOptions struct { Logger *io.Handler OCIClient oci.Client SigstoreVerifier verification.SigstoreVerifier - exporter cmdutil.Exporter Hostname string EC verification.EnforcementCriteria // Tenant is only set when tenancy is used diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 666ad3f45..15263a2c4 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -27,6 +27,10 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) Short: "Verify that a given asset originated from a specific GitHub Release.", Args: cobra.ExactArgs(2), PreRunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 2 { + return cmdutil.FlagErrorf("You must specify a tag and a file path") + } + opts.TagName = args[0] opts.FilePath = args[1] @@ -48,12 +52,10 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, Owner: baseRepo.RepoOwner(), - PredicateType: "https://in-toto.io/attestation/release/v0.1", + PredicateType: attestation.ReleasePredicateType, Logger: logger, HttpClient: httpClient, BaseRepo: baseRepo, - IO: f.IOStreams, - Exporter: opts.Exporter, } return nil }, @@ -91,8 +93,6 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return verifyAssetRun(opts) }, } - - cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.ReleaseFields) return cmd } diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 149125dc6..8c835d1e6 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -52,7 +52,7 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, Owner: baseRepo.RepoOwner(), - PredicateType: "https://in-toto.io/attestation/release/v0.1", + PredicateType: attestation.ReleasePredicateType, Logger: logger, HttpClient: httpClient, BaseRepo: baseRepo, @@ -94,9 +94,6 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro return verifyRun(opts) }, } - - cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.ReleaseFields) - return cmd } From 53d589223bed7f801d8b2fed67891d87acdebe18 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 21 May 2025 12:17:05 +0100 Subject: [PATCH 173/249] test: ensure proper usage of pipes in docs Signed-off-by: Babak K. Shandiz --- pkg/cmd/root/help_test.go | 76 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/pkg/cmd/root/help_test.go b/pkg/cmd/root/help_test.go index d24a84a0d..de82479a8 100644 --- a/pkg/cmd/root/help_test.go +++ b/pkg/cmd/root/help_test.go @@ -2,6 +2,18 @@ package root import ( "testing" + + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" ) func TestDedent(t *testing.T) { @@ -44,3 +56,67 @@ func TestDedent(t *testing.T) { } } } + +// Since our online docs website renders pages by using the kramdown (a superset +// of Markdown) engine, we have to check against some known quirks of the +// syntax. +func TestKramdownCompatibleDocs(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + Browser: &browser.Stub{}, + ExtensionManager: &extensions.ExtensionManagerMock{ + ListFunc: func() []extensions.Extension { + return nil + }, + }, + } + + cmd, err := NewCmdRoot(f, "N/A", "") + require.NoError(t, err) + + var walk func(*cobra.Command) + walk = func(cmd *cobra.Command) { + t.Run(cmd.UseLine(), func(t *testing.T) { + assertProperUsageOfPipes(t, cmd) + }) + for _, child := range cmd.Commands() { + walk(child) + } + } + + walk(cmd) +} + +// If not in a code block, kramdown treats pipes ("|") as table column +// separators, even if there's no table header, or left/right table row borders +// (i.e. lines starting and ending with a pipe). +// +// We need to assert there's no pipe in the text unless it's in a code-block or +// code-span. +// +// (See https://github.com/cli/cli/issues/10348) +func assertProperUsageOfPipes(t *testing.T, cmd *cobra.Command) { + md := goldmark.New() + reader := text.NewReader([]byte(cmd.Long)) + doc := md.Parser().Parse(reader) + + var checkNode func(node ast.Node) + checkNode = func(node ast.Node) { + if node.Kind() == ast.KindCodeSpan || node.Kind() == ast.KindCodeBlock { + return + } + + if node.Kind() == ast.KindText { + text := string(node.(*ast.Text).Segment.Value(reader.Source())) + require.NotContains(t, text, "|", `found pipe ("|") in plain text in %q docs`, cmd.CommandPath()) + } + + for child := node.FirstChild(); child != nil; child = child.NextSibling() { + checkNode(child) + } + } + + checkNode(doc) +} From 34431ab07486a180bd85cfd6bc976f891deb1614 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 21 May 2025 12:20:08 +0100 Subject: [PATCH 174/249] chore: run `go mod tidy` Signed-off-by: Babak K. Shandiz --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index bf8b033fe..f95c8a7c2 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 + github.com/yuin/goldmark v1.7.8 github.com/zalando/go-keyring v0.2.5 golang.org/x/crypto v0.37.0 golang.org/x/sync v0.13.0 @@ -171,7 +172,6 @@ require ( github.com/transparency-dev/merkle v0.0.2 // indirect github.com/vbatts/tar-split v0.11.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.7.8 // indirect 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 From e9fbe9d8b8d6ab301b3fc10c2d17dd08bbf55e83 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Wed, 21 May 2025 11:23:47 -0400 Subject: [PATCH 175/249] change verify-asset logic --- pkg/cmd/release/verify-asset/verify-asset.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 15263a2c4..1a74afdbf 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -141,7 +141,7 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { } if len(filteredAttestations) == 0 { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for %s\n"), fileName) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.FilePath, fileDigest.DigestWithAlg()) return nil } From 960aadf2b9a9e2005d599801ff15be27451d8c40 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 22 May 2025 17:04:04 +0100 Subject: [PATCH 176/249] test: improve test case naming Signed-off-by: Babak K. Shandiz --- pkg/cmd/root/help_test.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/root/help_test.go b/pkg/cmd/root/help_test.go index de82479a8..40f333159 100644 --- a/pkg/cmd/root/help_test.go +++ b/pkg/cmd/root/help_test.go @@ -1,6 +1,7 @@ package root import ( + "fmt" "testing" "github.com/cli/cli/v2/internal/browser" @@ -78,8 +79,9 @@ func TestKramdownCompatibleDocs(t *testing.T) { var walk func(*cobra.Command) walk = func(cmd *cobra.Command) { - t.Run(cmd.UseLine(), func(t *testing.T) { - assertProperUsageOfPipes(t, cmd) + name := fmt.Sprintf("%q: test pipes are in code blocks", cmd.UseLine()) + t.Run(name, func(t *testing.T) { + assertPipesAreInCodeBlocks(t, cmd) }) for _, child := range cmd.Commands() { walk(child) @@ -89,15 +91,15 @@ func TestKramdownCompatibleDocs(t *testing.T) { walk(cmd) } -// If not in a code block, kramdown treats pipes ("|") as table column -// separators, even if there's no table header, or left/right table row borders -// (i.e. lines starting and ending with a pipe). +// If not in a code block or a code span, kramdown treats pipes ("|") as table +// column separators, even if there's no table header, or left/right table row +// borders (i.e. lines starting and ending with a pipe). // // We need to assert there's no pipe in the text unless it's in a code-block or // code-span. // // (See https://github.com/cli/cli/issues/10348) -func assertProperUsageOfPipes(t *testing.T, cmd *cobra.Command) { +func assertPipesAreInCodeBlocks(t *testing.T, cmd *cobra.Command) { md := goldmark.New() reader := text.NewReader([]byte(cmd.Long)) doc := md.Parser().Parse(reader) From ca0f9847db0c582404f14105eff5de9e92f0af26 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Thu, 22 May 2025 12:31:45 -0400 Subject: [PATCH 177/249] add json format --- .github/CODEOWNERS | 4 +++ pkg/cmd/release/attestation/options.go | 12 +++++++-- pkg/cmd/release/verify-asset/verify-asset.go | 28 ++++++++++++++------ pkg/cmd/release/verify/verify.go | 12 +++++++++ 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 40683d917..5d39bf3af 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,6 +5,10 @@ internal/codespaces/ @cli/codespaces # Limit Package Security team ownership to the attestation command package and related integration tests pkg/cmd/attestation/ @cli/package-security +pkg/cmd/release/attestation @cli/package-security +pkg/cmd/release/verify @cli/package-security +pkg/cmd/release/verify-asset @cli/package-security + test/integration/attestation-cmd @cli/package-security pkg/cmd/attestation/verification/embed/tuf-repo.github.com/ @cli/tuf-root-reviewers diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/attestation/options.go index aadbc2f47..f0957c04b 100644 --- a/pkg/cmd/release/attestation/options.go +++ b/pkg/cmd/release/attestation/options.go @@ -3,6 +3,7 @@ package attestation import ( "fmt" "net/http" + "path/filepath" "strings" "github.com/cli/cli/v2/internal/gh" @@ -47,8 +48,15 @@ type AttestOptions struct { Hostname string EC verification.EnforcementCriteria // Tenant is only set when tenancy is used - Tenant string - FilePath string + Tenant string + AssetFilePath string +} + +// Clean cleans the file path option values +func (opts *AttestOptions) Clean() { + if opts.AssetFilePath != "" { + opts.AssetFilePath = filepath.Clean(opts.AssetFilePath) + } } // AreFlagsValid checks that the provided flag combination is valid diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 1a74afdbf..585643c2d 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -31,8 +31,8 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return cmdutil.FlagErrorf("You must specify a tag and a file path") } - opts.TagName = args[0] - opts.FilePath = args[1] + tagName := args[0] + assetFilePath := args[1] httpClient, err := f.HttpClient() if err != nil { @@ -46,8 +46,8 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) hostname, _ := ghauth.DefaultHost() *opts = attestation.AttestOptions{ - TagName: opts.TagName, - FilePath: opts.FilePath, + TagName: tagName, + AssetFilePath: assetFilePath, Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, @@ -93,15 +93,17 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return verifyAssetRun(opts) }, } + cmdutil.AddFormatFlags(cmd, &opts.Exporter) + return cmd } func verifyAssetRun(opts *attestation.AttestOptions) error { ctx := context.Background() - fileName := getFileName(opts.FilePath) + fileName := getFileName(opts.AssetFilePath) // calculate the digest of the file - fileDigest, err := artifact.NewDigestedArtifact(nil, opts.FilePath, "sha256") + fileDigest, err := artifact.NewDigestedArtifact(nil, opts.AssetFilePath, "sha256") if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to calculate file digest")) return err @@ -141,7 +143,7 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { } if len(filteredAttestations) == 0 { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.FilePath, fileDigest.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) return nil } @@ -152,10 +154,20 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.FilePath, fileDigest.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) return err } + // If an exporter is provided with the --json flag, write the results to the terminal in JSON format + if opts.Exporter != nil { + // print the results to the terminal as an array of JSON objects + if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to write JSON output")) + return err + } + return nil + } + opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseRefDigest.DigestWithAlg()) diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 8c835d1e6..e8c6621e5 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -94,6 +94,8 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro return verifyRun(opts) }, } + cmdutil.AddFormatFlags(cmd, &opts.Exporter) + return cmd } @@ -137,6 +139,16 @@ func verifyRun(opts *attestation.AttestOptions) error { return err } + // If an exporter is provided with the --json flag, write the results to the terminal in JSON format + if opts.Exporter != nil { + // print the results to the terminal as an array of JSON objects + if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to write JSON output")) + return err + } + return nil + } + opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) From 1e6a2b1affb7d6a7850f4bc0e7a43cf14b80caf5 Mon Sep 17 00:00:00 2001 From: Azeem Sajid Date: Fri, 23 May 2025 14:55:41 +0500 Subject: [PATCH 178/249] Add `--compact` flag to `run watch` (#10629) * [gh run watch] Support `--compact` flag * [gh run watch] Support `--compact` flag * Add changes for updated AC * Incorporate review changes * docs(run watch): fill up the line to 80 chars Signed-off-by: Babak K. Shandiz --------- Signed-off-by: Babak K. Shandiz Co-authored-by: Babak K. Shandiz --- pkg/cmd/run/shared/presentation.go | 42 ++++++++++++++++++++++++++++++ pkg/cmd/run/watch/watch.go | 15 +++++++++-- pkg/cmd/run/watch/watch_test.go | 9 +++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/run/shared/presentation.go b/pkg/cmd/run/shared/presentation.go index a3556d743..983979d8a 100644 --- a/pkg/cmd/run/shared/presentation.go +++ b/pkg/cmd/run/shared/presentation.go @@ -47,6 +47,48 @@ func RenderJobs(cs *iostreams.ColorScheme, jobs []Job, verbose bool) string { return strings.Join(lines, "\n") } +func RenderJobsCompact(cs *iostreams.ColorScheme, jobs []Job) string { + lines := []string{} + for _, job := range jobs { + elapsed := job.CompletedAt.Sub(job.StartedAt) + elapsedStr := fmt.Sprintf(" in %s", elapsed) + if elapsed < 0 { + elapsedStr = "" + } + symbol, symbolColor := Symbol(cs, job.Status, job.Conclusion) + id := cs.Cyanf("%d", job.ID) + lines = append(lines, fmt.Sprintf("%s %s%s (ID %s)", symbolColor(symbol), cs.Bold(job.Name), elapsedStr, id)) + + if job.Status == Completed && job.Conclusion == Success { + continue + } + + var inProgressStepLine string + var failedStepLines []string + + for _, step := range job.Steps { + stepSymbol, stepSymColor := Symbol(cs, step.Status, step.Conclusion) + stepLine := fmt.Sprintf(" %s %s", stepSymColor(stepSymbol), step.Name) + + if IsFailureState(step.Conclusion) { + failedStepLines = append(failedStepLines, stepLine) + } + + if step.Status == InProgress { + inProgressStepLine = stepLine + } + } + + lines = append(lines, failedStepLines...) + + if inProgressStepLine != "" { + lines = append(lines, inProgressStepLine) + } + } + + return strings.Join(lines, "\n") +} + func RenderAnnotations(cs *iostreams.ColorScheme, annotations []Annotation) string { lines := []string{} diff --git a/pkg/cmd/run/watch/watch.go b/pkg/cmd/run/watch/watch.go index 57be01f64..a73a91e1a 100644 --- a/pkg/cmd/run/watch/watch.go +++ b/pkg/cmd/run/watch/watch.go @@ -28,6 +28,7 @@ type WatchOptions struct { RunID string Interval int ExitStatus bool + Compact bool Prompt bool @@ -48,6 +49,9 @@ func NewCmdWatch(f *cmdutil.Factory, runF func(*WatchOptions) error) *cobra.Comm Long: heredoc.Docf(` Watch a run until it completes, showing its progress. + By default, all steps are displayed. The %[1]s--compact%[1]s option can be used to only + show the relevant/failed steps. + 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. `, "`"), @@ -55,6 +59,9 @@ func NewCmdWatch(f *cmdutil.Factory, runF func(*WatchOptions) error) *cobra.Comm # Watch a run until it's done $ gh run watch + # Watch a run in compact mode + $ gh run watch --compact + # Run some other command when the run is finished $ gh run watch && notify-send 'run is done!' `), @@ -78,6 +85,7 @@ func NewCmdWatch(f *cmdutil.Factory, runF func(*WatchOptions) error) *cobra.Comm }, } cmd.Flags().BoolVar(&opts.ExitStatus, "exit-status", false, "Exit with non-zero status if run fails") + cmd.Flags().BoolVar(&opts.Compact, "compact", false, "Show only relevant/failed steps") cmd.Flags().IntVarP(&opts.Interval, "interval", "i", defaultInterval, "Refresh interval in seconds") return cmd @@ -252,8 +260,11 @@ func renderRun(out io.Writer, opts WatchOptions, client *api.Client, repo ghrepo } fmt.Fprintln(out, cs.Bold("JOBS")) - - fmt.Fprintln(out, shared.RenderJobs(cs, jobs, true)) + if opts.Compact { + fmt.Fprintln(out, shared.RenderJobsCompact(cs, jobs)) + } else { + fmt.Fprintln(out, shared.RenderJobs(cs, jobs, true)) + } if missingAnnotationsPermissions { fmt.Fprintln(out) diff --git a/pkg/cmd/run/watch/watch_test.go b/pkg/cmd/run/watch/watch_test.go index 49e56217b..1471f64cf 100644 --- a/pkg/cmd/run/watch/watch_test.go +++ b/pkg/cmd/run/watch/watch_test.go @@ -57,6 +57,15 @@ func TestNewCmdWatch(t *testing.T) { ExitStatus: true, }, }, + { + name: "compact status", + cli: "1234 --compact", + wants: WatchOptions{ + Interval: defaultInterval, + RunID: "1234", + Compact: true, + }, + }, } for _, tt := range tests { From bb5e10d41f9568765b013a2e723e12aa5139e991 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 23 May 2025 10:58:48 +0100 Subject: [PATCH 179/249] test(run): add tests for `RenderJobs` and `RenderJobsCompact` Signed-off-by: Babak K. Shandiz --- pkg/cmd/run/shared/presentation_test.go | 398 ++++++++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 pkg/cmd/run/shared/presentation_test.go diff --git a/pkg/cmd/run/shared/presentation_test.go b/pkg/cmd/run/shared/presentation_test.go new file mode 100644 index 000000000..f91e38907 --- /dev/null +++ b/pkg/cmd/run/shared/presentation_test.go @@ -0,0 +1,398 @@ +package shared + +import ( + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderJobs(t *testing.T) { + startedAt, err := time.Parse(time.RFC3339, "2009-03-19T00:00:00Z") + require.NoError(t, err) + completedAt, err := time.Parse(time.RFC3339, "2009-03-19T00:01:00Z") + require.NoError(t, err) + + tests := []struct { + name string + jobs []Job + wantVerbose string + wantNormal string + wantCompact string + }{ + { + name: "nil jobs", + jobs: nil, + }, + { + name: "empty jobs", + jobs: []Job{}, + }, + { + // This is not a real-world case, but nevertheless the code should + // be able to handle that without error/panic. + name: "in-progress job without steps", + jobs: []Job{ + { + Name: "foo", + ID: 999, + StartedAt: startedAt, + Status: InProgress, + }, + }, + wantCompact: heredoc.Doc(` + * foo (ID 999)`), + wantNormal: heredoc.Doc(` + * foo (ID 999)`), + wantVerbose: heredoc.Doc(` + * foo (ID 999)`), + }, + { + // This is not a real-world case, but nevertheless the code should + // be able to handle that without error/panic. + name: "successful job without steps", + jobs: []Job{ + { + Name: "foo", + ID: 999, + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Success, + }, + }, + wantCompact: heredoc.Doc(` + ✓ foo in 1m0s (ID 999)`), + wantNormal: heredoc.Doc(` + ✓ foo in 1m0s (ID 999)`), + wantVerbose: heredoc.Doc(` + ✓ foo in 1m0s (ID 999)`), + }, + { + // This is not a real-world case, but nevertheless the code should + // be able to handle that without error/panic. + name: "failed job without steps", + jobs: []Job{ + { + Name: "foo", + ID: 999, + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Failure, + }, + }, + wantCompact: heredoc.Doc(` + X foo in 1m0s (ID 999)`), + wantNormal: heredoc.Doc(` + X foo in 1m0s (ID 999)`), + wantVerbose: heredoc.Doc(` + X foo in 1m0s (ID 999)`), + }, + { + name: "in-progress job with various step status values", + jobs: []Job{ + { + Name: "foo", + ID: 999, + StartedAt: startedAt, + Status: InProgress, + Steps: []Step{ + { + Name: "passed", + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "skipped", + Status: Completed, + Conclusion: Skipped, + Number: 2, + }, + { + Name: "failed 1", + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Failure, + Number: 3, + }, + { + Name: "failed 2", + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Failure, + Number: 4, + }, + { + Name: "in-progress", + StartedAt: startedAt, + Status: InProgress, + Number: 5, + }, + { + Name: "pending", + Status: Pending, + Number: 6, + }, + }, + }, + }, + wantCompact: heredoc.Doc(` + * foo (ID 999) + X failed 1 + X failed 2 + * in-progress`), + wantNormal: heredoc.Doc(` + * foo (ID 999)`), + wantVerbose: heredoc.Doc(` + * foo (ID 999) + ✓ passed + - skipped + X failed 1 + X failed 2 + * in-progress + * pending`), + }, + { + // As of my observations (babakks) when there is a failed step, the + // job run is marked as failed. In other words, a successful job run + // cannot have any failed steps. That's why there's no failed steps + // in this test case. + name: "successful job with various step status values", + jobs: []Job{ + { + Name: "foo", + ID: 999, + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Success, + Steps: []Step{ + { + Name: "passed 1", + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "skipped", + Status: Completed, + Conclusion: Skipped, + Number: 2, + }, + { + Name: "passed 2", + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Success, + Number: 3, + }, + }, + }, + }, + wantCompact: heredoc.Doc(` + ✓ foo in 1m0s (ID 999)`), + wantNormal: heredoc.Doc(` + ✓ foo in 1m0s (ID 999)`), + wantVerbose: heredoc.Doc(` + ✓ foo in 1m0s (ID 999) + ✓ passed 1 + - skipped + ✓ passed 2`), + }, + { + name: "failed job with various step status values", + jobs: []Job{ + { + Name: "foo", + ID: 999, + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Failure, + Steps: []Step{ + { + Name: "passed 1", + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "skipped", + Status: Completed, + Conclusion: Skipped, + Number: 2, + }, + { + Name: "failed 1", + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Failure, + Number: 3, + }, + { + Name: "failed 2", + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Failure, + Number: 4, + }, + { + Name: "passed 2", + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Success, + Number: 5, + }, + }, + }, + }, + wantCompact: heredoc.Doc(` + X foo in 1m0s (ID 999) + X failed 1 + X failed 2`), + wantNormal: heredoc.Doc(` + X foo in 1m0s (ID 999) + ✓ passed 1 + - skipped + X failed 1 + X failed 2 + ✓ passed 2`), + wantVerbose: heredoc.Doc(` + X foo in 1m0s (ID 999) + ✓ passed 1 + - skipped + X failed 1 + X failed 2 + ✓ passed 2`), + }, + { + name: "multiple jobs", + jobs: []Job{ + { + Name: "in-progress", + ID: 999, + StartedAt: startedAt, + Status: InProgress, + Steps: []Step{ + { + Name: "passed", + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "in-progress", + StartedAt: startedAt, + Status: InProgress, + Number: 2, + }, + }, + }, + { + Name: "successful", + ID: 999, + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Success, + Steps: []Step{ + { + Name: "passed", + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "skipped", + Status: Completed, + Conclusion: Skipped, + Number: 2, + }, + }, + }, + { + Name: "failed", + ID: 999, + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Failure, + Steps: []Step{ + { + Name: "passed", + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Success, + Number: 1, + }, + { + Name: "failed", + StartedAt: startedAt, + CompletedAt: completedAt, + Status: Completed, + Conclusion: Failure, + Number: 2, + }, + }, + }, + }, + wantCompact: heredoc.Doc(` + * in-progress (ID 999) + * in-progress + ✓ successful in 1m0s (ID 999) + X failed in 1m0s (ID 999) + X failed`), + wantNormal: heredoc.Doc(` + * in-progress (ID 999) + ✓ successful in 1m0s (ID 999) + X failed in 1m0s (ID 999) + ✓ passed + X failed`), + wantVerbose: heredoc.Doc(` + * in-progress (ID 999) + ✓ passed + * in-progress + ✓ successful in 1m0s (ID 999) + ✓ passed + - skipped + X failed in 1m0s (ID 999) + ✓ passed + X failed`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotCompact := RenderJobsCompact(&iostreams.ColorScheme{}, tt.jobs) + assert.Equal(t, tt.wantCompact, gotCompact, "unexpected compact mode output") + + gotNormal := RenderJobs(&iostreams.ColorScheme{}, tt.jobs, false) + assert.Equal(t, tt.wantNormal, gotNormal, "unexpected normal mode output") + + gotVerbose := RenderJobs(&iostreams.ColorScheme{}, tt.jobs, true) + assert.Equal(t, tt.wantVerbose, gotVerbose, "unexpected verbose mode output") + }) + } +} From 3108d99208273dad1b91813f7edffee409a00cd6 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 23 May 2025 15:31:33 -0400 Subject: [PATCH 180/249] added the unit test --- .../test/data/release-attestation.json | 24 +++++++ pkg/cmd/release/attestation/attestation.go | 7 -- pkg/cmd/release/attestation/options.go | 7 -- pkg/cmd/release/attestation/options_test.go | 72 +++++++++++++++++++ pkg/cmd/release/attestation/policy.go | 6 +- pkg/cmd/release/attestation/policy_test.go | 71 ++++++++++++++++++ pkg/cmd/release/shared/fetch.go | 2 +- pkg/cmd/release/verify-asset/verify-asset.go | 30 +++++--- pkg/cmd/release/verify/verify.go | 29 +++++--- 9 files changed, 213 insertions(+), 35 deletions(-) create mode 100644 pkg/cmd/attestation/test/data/release-attestation.json create mode 100644 pkg/cmd/release/attestation/options_test.go create mode 100644 pkg/cmd/release/attestation/policy_test.go diff --git a/pkg/cmd/attestation/test/data/release-attestation.json b/pkg/cmd/attestation/test/data/release-attestation.json new file mode 100644 index 000000000..ae8dd1b56 --- /dev/null +++ b/pkg/cmd/attestation/test/data/release-attestation.json @@ -0,0 +1,24 @@ +{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "timestampVerificationData": { + "rfc3161Timestamps": [ + { + "signedTimestamp": "MIIC0TADAgEAMIICyAYJKoZIhvcNAQcCoIICuTCCArUCAQMxDTALBglghkgBZQMEAgIwgbwGCyqGSIb3DQEJEAEEoIGsBIGpMIGmAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgGvFc6nUuLhnXfhM9p0DV91c5kHvafP1hs9BX8KYeeSYCFQDhjGrIIiaH/jkMdN6HUsErnUfrlRgPMjAyNTA1MTMyMzAzNTFaMAMCAQGgNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIFRpbWVzdGFtcGluZ6AAMYIB3jCCAdoCAQEwSjAyMRUwEwYDVQQKEwxHaXRIdWIsIEluYy4xGTAXBgNVBAMTEFRTQSBpbnRlcm1lZGlhdGUCFB+7MIjE5/rL4XA4fNDnmXHA04+wMAsGCWCGSAFlAwQCAqCCAQUwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNTA1MTMyMzAzNTFaMD8GCSqGSIb3DQEJBDEyBDDVh2oDCJy7ustugLKfVcUSNjo5M2MFMNKIU11sIQDCNOo5gbj9R97sCWXNnfmUztMwgYcGCyqGSIb3DQEJEAIvMXgwdjB0MHIEIHuISsKSyiJtlhGjT+RyS+tYQ7iwCMsMCTGmz2NK3D7DME4wNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIGludGVybWVkaWF0ZQIUH7swiMTn+svhcDh80OeZccDTj7AwCgYIKoZIzj0EAwMEZzBlAjAqp/fYVfQcU9aMcmTIZvb0cxk00OaVBYLzuiIvcRqkMdAJiz/gSxOWU0AQjEPskHUCMQCrUKlZR4shPZuMvY6CCUOhxxKq/6LUoccWNHyL6sGkHRXE7j9HETh4uLKzRwNDVVA=" + } + ] + }, + "certificate": { + "rawBytes": "MIICKjCCAbCgAwIBAgIUaa62dj98DUB+TpyvKtVaR4vGSM0wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI1MDMxMDE1MDMwMloXDTI2MDMxMDE1MDMwMlowKjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMREwDwYDVQQDEwhBdHRlc3RlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIMB7plPnZvBRlC2lvAocKTAqAPMJqstEqYk26e9vDJDC1yqoiHxZfPV4W/1RqUMZD1dFKm9t4RiSmm73/QnQKajgaUwgaIwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFOqaGpr5SbdYk5CQXsmmDZCBHR+XMB8GA1UdIwQYMBaAFMDhuFKkS08+3no4EQbPSY6hRZszMC0GA1UdEQQmMCSGImh0dHBzOi8vZG90Y29tLnJlbGVhc2VzLmdpdGh1Yi5jb20wCgYIKoZIzj0EAwMDaAAwZQIwWFdF6xcXazHVPHEAtd1SeaizLdY1erRl5hK+XlwhfpnasQHHZ9bdu4Zj8ARhW/AhAjEArujhmJGo7Fi4/Ek1RN8bufs6UhIQneQd/pxE8QdorwZkj2C8nf2EzrUYzlxKfktC" + } + }, + "dsseEnvelope": { + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJ1cmkiOiJwa2c6Z2l0aHViL2JkZWhhbWVyL2RlbG1lQHY1IiwiZGlnZXN0Ijp7InNoYTEiOiJjNWUxN2E2MmUwNmExZDIwMTU3MDI0OWM2MWZhZTUzMWU5MjQ0ZTFiIn19LHsibmFtZSI6ImEuemlwIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImY3MTY1ODQ4ZjlmNWRkYzU3OGQ3YWRiZDFmNTY2YTM5NDE2OTM4NWM3M2JkODhiZjYwZGY3ZTc1OWRiOGUwOGQifX0seyJuYW1lIjoiYi56aXAiLCJkaWdlc3QiOnsic2hhMjU2IjoiOGI3ZWIxNTcyMzQ2NjkyZmZkM2FlMDEyNDhjNzBhMzQxYWUzYWE4YmUxZGY4YjEyMzQ2YjUwYWNiOTAwMjI4MiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2luLXRvdG8uaW8vYXR0ZXN0YXRpb24vcmVsZWFzZS92MC4xIiwicHJlZGljYXRlIjp7Im93bmVySWQiOiIzOTgwMjciLCJwdXJsIjoicGtnOmdpdGh1Yi9iZGVoYW1lci9kZWxtZUB2NSIsInJlbGVhc2VJZCI6IjIxODQxOTIxNyIsInJlcG9zaXRvcnkiOiJiZGVoYW1lci9kZWxtZSIsInJlcG9zaXRvcnlJZCI6IjkwNTk4ODA0NCIsInRhZyI6InY1In19", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEQCIH6LDUanQYOCPovZlIqI1cE49SiGJdexR65qsAZHohsZAiA9w3usgPWtgn5voB8bRvpJQtjEVqC5eMDh3mJEdyMcXw==" + } + ] + } +} \ No newline at end of file diff --git a/pkg/cmd/release/attestation/attestation.go b/pkg/cmd/release/attestation/attestation.go index 08e1398b8..bf2f39a7c 100644 --- a/pkg/cmd/release/attestation/attestation.go +++ b/pkg/cmd/release/attestation/attestation.go @@ -50,13 +50,6 @@ func VerifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, s return nil, logMsg, err } - // Verify extensions - // certExtVerified, err := verification.VerifyCertExtensions(sigstoreVerified, ec) - // if err != nil { - // logMsg := "✗ Policy verification failed" - // return nil, logMsg, err - // } - return sigstoreVerified, "", nil } diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/attestation/options.go index f0957c04b..9dd84647e 100644 --- a/pkg/cmd/release/attestation/options.go +++ b/pkg/cmd/release/attestation/options.go @@ -19,7 +19,6 @@ import ( const ReleasePredicateType = "https://in-toto.io/attestation/release/v0.1" -// AttestOptions captures the options for the verify command type AttestOptions struct { Config func() (gh.Config, error) HttpClient *http.Client @@ -67,17 +66,11 @@ func (opts *AttestOptions) AreFlagsValid() error { return fmt.Errorf("invalid value provided for repo: %s", opts.Repo) } - // If provided, check that the SignerRepo option is in the expected format / - if opts.SignerRepo != "" && !isProvidedRepoValid(opts.SignerRepo) { - return fmt.Errorf("invalid value provided for signer-repo: %s", opts.SignerRepo) - } - // Check that limit is between 1 and 1000 if opts.Limit < 1 || opts.Limit > 1000 { return fmt.Errorf("limit %d not allowed, must be between 1 and 1000", opts.Limit) } - // Verify provided hostname if opts.Hostname != "" { if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { return fmt.Errorf("error parsing hostname: %w", err) diff --git a/pkg/cmd/release/attestation/options_test.go b/pkg/cmd/release/attestation/options_test.go new file mode 100644 index 000000000..00bba29a5 --- /dev/null +++ b/pkg/cmd/release/attestation/options_test.go @@ -0,0 +1,72 @@ +package attestation + +import ( + "errors" + "testing" +) + +func TestAttestOptions_Clean(t *testing.T) { + opts := &AttestOptions{ + AssetFilePath: "foo/bar/../baz.txt", + } + opts.Clean() + expected := "foo/baz.txt" + if opts.AssetFilePath != expected && opts.AssetFilePath != "./foo/baz.txt" { // OS differences + t.Errorf("expected AssetFilePath to be cleaned to %q, got %q", expected, opts.AssetFilePath) + } +} + +func TestAttestOptions_AreFlagsValid_Valid(t *testing.T) { + opts := &AttestOptions{ + Repo: "owner/repo", + SignerRepo: "signer/repo", + Limit: 10, + } + if err := opts.AreFlagsValid(); err != nil { + t.Errorf("expected no error, got %v", err) + } +} + +func TestAttestOptions_AreFlagsValid_InvalidRepo(t *testing.T) { + opts := &AttestOptions{ + Repo: "invalidrepo", + } + err := opts.AreFlagsValid() + if err == nil || !errors.Is(err, err) { + t.Errorf("expected error for invalid repo, got %v", err) + } +} + +func TestAttestOptions_AreFlagsValid_LimitTooLow(t *testing.T) { + opts := &AttestOptions{ + Repo: "owner/repo", + Limit: 0, + } + err := opts.AreFlagsValid() + if err == nil || !errors.Is(err, err) { + t.Errorf("expected error for limit too low, got %v", err) + } +} + +func TestAttestOptions_AreFlagsValid_LimitTooHigh(t *testing.T) { + opts := &AttestOptions{ + Repo: "owner/repo", + Limit: 1001, + } + err := opts.AreFlagsValid() + if err == nil || !errors.Is(err, err) { + t.Errorf("expected error for limit too high, got %v", err) + } +} + +func TestAttestOptions_AreFlagsValid_ValidHostname(t *testing.T) { + opts := &AttestOptions{ + Repo: "owner/repo", + Limit: 10, + Hostname: "github.com", + } + err := opts.AreFlagsValid() + if err != nil { + t.Errorf("expected no error for valid hostname, got %v", err) + } +} diff --git a/pkg/cmd/release/attestation/policy.go b/pkg/cmd/release/attestation/policy.go index 7dfb88cfe..d7bf0f096 100644 --- a/pkg/cmd/release/attestation/policy.go +++ b/pkg/cmd/release/attestation/policy.go @@ -3,7 +3,6 @@ package attestation import ( "fmt" - att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" "github.com/sigstore/sigstore-go/pkg/verify" @@ -18,12 +17,11 @@ func expandToGitHubURL(tenant, ownerOrRepo string) string { return fmt.Sprintf("https://%s.ghe.com/%s", tenant, ownerOrRepo) } -// TODO: revist this policy -func NewEnforcementCriteria(opts *AttestOptions, logger *att_io.Handler) (verification.EnforcementCriteria, error) { +func NewEnforcementCriteria(opts *AttestOptions) (verification.EnforcementCriteria, error) { // initialize the enforcement criteria with the provided PredicateType and SAN c := verification.EnforcementCriteria{ PredicateType: opts.PredicateType, - // if the proxima is provided, the default uses the proxima-specific SAN + // TODO: if the proxima is provided, the default uses the proxima-specific SAN SAN: "https://dotcom.releases.github.com", } diff --git a/pkg/cmd/release/attestation/policy_test.go b/pkg/cmd/release/attestation/policy_test.go new file mode 100644 index 000000000..57eab86b2 --- /dev/null +++ b/pkg/cmd/release/attestation/policy_test.go @@ -0,0 +1,71 @@ +package attestation + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewEnforcementCriteria(t *testing.T) { + t.Run("check SAN", func(t *testing.T) { + opts := &AttestOptions{ + Owner: "foo", + Repo: "foo/bar", + PredicateType: "https://in-toto.io/attestation/release/v0.1", + } + + c, err := NewEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "https://dotcom.releases.github.com", c.SAN) + require.Equal(t, "https://in-toto.io/attestation/release/v0.1", c.PredicateType) + }) + + t.Run("sets Extensions.SourceRepositoryURI using opts.Repo and opts.Tenant", func(t *testing.T) { + opts := &AttestOptions{ + Owner: "foo", + Repo: "foo/bar", + Tenant: "baz", + } + + c, err := NewEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "https://baz.ghe.com/foo/bar", c.Certificate.SourceRepositoryURI) + }) + + t.Run("sets Extensions.SourceRepositoryURI using opts.Repo", func(t *testing.T) { + opts := &AttestOptions{ + Owner: "foo", + Repo: "foo/bar", + } + + c, err := NewEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "https://github.com/foo/bar", c.Certificate.SourceRepositoryURI) + }) + + t.Run("sets Extensions.SourceRepositoryOwnerURI using opts.Owner and opts.Tenant", func(t *testing.T) { + opts := &AttestOptions{ + + Owner: "foo", + Repo: "foo/bar", + Tenant: "baz", + } + + c, err := NewEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "https://baz.ghe.com/foo", c.Certificate.SourceRepositoryOwnerURI) + }) + + t.Run("sets Extensions.SourceRepositoryOwnerURI using opts.Owner", func(t *testing.T) { + opts := &AttestOptions{ + + Owner: "foo", + Repo: "foo/bar", + } + + c, err := NewEnforcementCriteria(opts) + require.NoError(t, err) + require.Equal(t, "https://github.com/foo", c.Certificate.SourceRepositoryOwnerURI) + }) + +} diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 5fea30b7c..3daa1d3fc 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -132,7 +132,7 @@ type fetchResult struct { } func FetchRefSHA(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (string, error) { - path := fmt.Sprintf("repos/%s/%s/git/refs/tags/%s", repo.RepoOwner(), repo.RepoName(), tagName) + path := fmt.Sprintf("repos/%s/git/refs/tags/%s", repo.RepoOwner(), repo.RepoName(), tagName) req, err := http.NewRequestWithContext(ctx, "GET", ghinstance.RESTPrefix(repo.RepoHost())+path, nil) if err != nil { return "", err diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 585643c2d..ddefdf5be 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -5,6 +5,7 @@ import ( "errors" "path/filepath" + "github.com/cli/cli/v2/pkg/cmd/attestation/auth" ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/cli/cli/v2/internal/text" @@ -23,9 +24,10 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) opts := &attestation.AttestOptions{} cmd := &cobra.Command{ - Use: "verify-asset ", - Short: "Verify that a given asset originated from a specific GitHub Release.", - Args: cobra.ExactArgs(2), + Use: "verify-asset ", + Short: "Verify that a given asset originated from a specific GitHub Release.", + Hidden: true, + Args: cobra.ExactArgs(2), PreRunE: func(cmd *cobra.Command, args []string) error { if len(args) < 2 { return cmdutil.FlagErrorf("You must specify a tag and a file path") @@ -45,6 +47,11 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) logger := att_io.NewHandler(f.IOStreams) hostname, _ := ghauth.DefaultHost() + err = auth.IsHostSupported(hostname) + if err != nil { + return err + } + *opts = attestation.AttestOptions{ TagName: tagName, AssetFilePath: assetFilePath, @@ -56,21 +63,24 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) Logger: logger, HttpClient: httpClient, BaseRepo: baseRepo, + Hostname: hostname, } + + // Check that the given flag combination is valid + if err := opts.AreFlagsValid(); err != nil { + return err + } + return nil }, RunE: func(cmd *cobra.Command, args []string) error { - if runF != nil { - return runF(opts) - } - td, err := opts.APIClient.GetTrustDomain() if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) return err } - ec, err := attestation.NewEnforcementCriteria(opts, opts.Logger) + ec, err := attestation.NewEnforcementCriteria(opts) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err @@ -90,6 +100,10 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) opts.SigstoreVerifier = sigstoreVerifier opts.EC = ec + if runF != nil { + return runF(opts) + } + return verifyAssetRun(opts) }, } diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index e8c6621e5..96c33c50b 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + "github.com/cli/cli/v2/pkg/cmd/attestation/auth" att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmd/release/attestation" @@ -24,9 +25,10 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro opts := &attestation.AttestOptions{} cmd := &cobra.Command{ - Use: "verify []", - Short: "Verify the attestation for a GitHub Release.", - Args: cobra.ExactArgs(1), + Use: "verify []", + Short: "Verify the attestation for a GitHub Release.", + Hidden: true, + Args: cobra.ExactArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { if len(args) < 1 { return cmdutil.FlagErrorf("You must specify a tag") @@ -46,6 +48,11 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro logger := att_io.NewHandler(f.IOStreams) hostname, _ := ghauth.DefaultHost() + err = auth.IsHostSupported(hostname) + if err != nil { + return err + } + *opts = attestation.AttestOptions{ TagName: opts.TagName, Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), @@ -56,21 +63,23 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro Logger: logger, HttpClient: httpClient, BaseRepo: baseRepo, + Hostname: hostname, + } + + // Check that the given flag combination is valid + if err := opts.AreFlagsValid(); err != nil { + return err } return nil }, RunE: func(cmd *cobra.Command, args []string) error { - if runF != nil { - return runF(opts) - } - td, err := opts.APIClient.GetTrustDomain() if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) return err } - ec, err := attestation.NewEnforcementCriteria(opts, opts.Logger) + ec, err := attestation.NewEnforcementCriteria(opts) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err @@ -91,6 +100,10 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro opts.SigstoreVerifier = sigstoreVerifier opts.EC = ec + if runF != nil { + return runF(opts) + } + return verifyRun(opts) }, } From 81f1017fa2e4633fdae6f4862d6eb1ab23d650b7 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 23 May 2025 15:33:43 -0400 Subject: [PATCH 181/249] removed unused file --- .../test/data/release-attestation.json | 24 ------------------- 1 file changed, 24 deletions(-) delete mode 100644 pkg/cmd/attestation/test/data/release-attestation.json diff --git a/pkg/cmd/attestation/test/data/release-attestation.json b/pkg/cmd/attestation/test/data/release-attestation.json deleted file mode 100644 index ae8dd1b56..000000000 --- a/pkg/cmd/attestation/test/data/release-attestation.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", - "verificationMaterial": { - "timestampVerificationData": { - "rfc3161Timestamps": [ - { - "signedTimestamp": "MIIC0TADAgEAMIICyAYJKoZIhvcNAQcCoIICuTCCArUCAQMxDTALBglghkgBZQMEAgIwgbwGCyqGSIb3DQEJEAEEoIGsBIGpMIGmAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgGvFc6nUuLhnXfhM9p0DV91c5kHvafP1hs9BX8KYeeSYCFQDhjGrIIiaH/jkMdN6HUsErnUfrlRgPMjAyNTA1MTMyMzAzNTFaMAMCAQGgNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIFRpbWVzdGFtcGluZ6AAMYIB3jCCAdoCAQEwSjAyMRUwEwYDVQQKEwxHaXRIdWIsIEluYy4xGTAXBgNVBAMTEFRTQSBpbnRlcm1lZGlhdGUCFB+7MIjE5/rL4XA4fNDnmXHA04+wMAsGCWCGSAFlAwQCAqCCAQUwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNTA1MTMyMzAzNTFaMD8GCSqGSIb3DQEJBDEyBDDVh2oDCJy7ustugLKfVcUSNjo5M2MFMNKIU11sIQDCNOo5gbj9R97sCWXNnfmUztMwgYcGCyqGSIb3DQEJEAIvMXgwdjB0MHIEIHuISsKSyiJtlhGjT+RyS+tYQ7iwCMsMCTGmz2NK3D7DME4wNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIGludGVybWVkaWF0ZQIUH7swiMTn+svhcDh80OeZccDTj7AwCgYIKoZIzj0EAwMEZzBlAjAqp/fYVfQcU9aMcmTIZvb0cxk00OaVBYLzuiIvcRqkMdAJiz/gSxOWU0AQjEPskHUCMQCrUKlZR4shPZuMvY6CCUOhxxKq/6LUoccWNHyL6sGkHRXE7j9HETh4uLKzRwNDVVA=" - } - ] - }, - "certificate": { - "rawBytes": "MIICKjCCAbCgAwIBAgIUaa62dj98DUB+TpyvKtVaR4vGSM0wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI1MDMxMDE1MDMwMloXDTI2MDMxMDE1MDMwMlowKjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMREwDwYDVQQDEwhBdHRlc3RlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIMB7plPnZvBRlC2lvAocKTAqAPMJqstEqYk26e9vDJDC1yqoiHxZfPV4W/1RqUMZD1dFKm9t4RiSmm73/QnQKajgaUwgaIwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFOqaGpr5SbdYk5CQXsmmDZCBHR+XMB8GA1UdIwQYMBaAFMDhuFKkS08+3no4EQbPSY6hRZszMC0GA1UdEQQmMCSGImh0dHBzOi8vZG90Y29tLnJlbGVhc2VzLmdpdGh1Yi5jb20wCgYIKoZIzj0EAwMDaAAwZQIwWFdF6xcXazHVPHEAtd1SeaizLdY1erRl5hK+XlwhfpnasQHHZ9bdu4Zj8ARhW/AhAjEArujhmJGo7Fi4/Ek1RN8bufs6UhIQneQd/pxE8QdorwZkj2C8nf2EzrUYzlxKfktC" - } - }, - "dsseEnvelope": { - "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJ1cmkiOiJwa2c6Z2l0aHViL2JkZWhhbWVyL2RlbG1lQHY1IiwiZGlnZXN0Ijp7InNoYTEiOiJjNWUxN2E2MmUwNmExZDIwMTU3MDI0OWM2MWZhZTUzMWU5MjQ0ZTFiIn19LHsibmFtZSI6ImEuemlwIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImY3MTY1ODQ4ZjlmNWRkYzU3OGQ3YWRiZDFmNTY2YTM5NDE2OTM4NWM3M2JkODhiZjYwZGY3ZTc1OWRiOGUwOGQifX0seyJuYW1lIjoiYi56aXAiLCJkaWdlc3QiOnsic2hhMjU2IjoiOGI3ZWIxNTcyMzQ2NjkyZmZkM2FlMDEyNDhjNzBhMzQxYWUzYWE4YmUxZGY4YjEyMzQ2YjUwYWNiOTAwMjI4MiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2luLXRvdG8uaW8vYXR0ZXN0YXRpb24vcmVsZWFzZS92MC4xIiwicHJlZGljYXRlIjp7Im93bmVySWQiOiIzOTgwMjciLCJwdXJsIjoicGtnOmdpdGh1Yi9iZGVoYW1lci9kZWxtZUB2NSIsInJlbGVhc2VJZCI6IjIxODQxOTIxNyIsInJlcG9zaXRvcnkiOiJiZGVoYW1lci9kZWxtZSIsInJlcG9zaXRvcnlJZCI6IjkwNTk4ODA0NCIsInRhZyI6InY1In19", - "payloadType": "application/vnd.in-toto+json", - "signatures": [ - { - "sig": "MEQCIH6LDUanQYOCPovZlIqI1cE49SiGJdexR65qsAZHohsZAiA9w3usgPWtgn5voB8bRvpJQtjEVqC5eMDh3mJEdyMcXw==" - } - ] - } -} \ No newline at end of file From d0da9b16642706e60d89d330ddd410a9d688bcfb Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 23 May 2025 16:23:41 -0400 Subject: [PATCH 182/249] update Sprintf --- pkg/cmd/release/shared/fetch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 3daa1d3fc..5fea30b7c 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -132,7 +132,7 @@ type fetchResult struct { } func FetchRefSHA(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (string, error) { - path := fmt.Sprintf("repos/%s/git/refs/tags/%s", repo.RepoOwner(), repo.RepoName(), tagName) + path := fmt.Sprintf("repos/%s/%s/git/refs/tags/%s", repo.RepoOwner(), repo.RepoName(), tagName) req, err := http.NewRequestWithContext(ctx, "GET", ghinstance.RESTPrefix(repo.RepoHost())+path, nil) if err != nil { return "", err From 9594937d589b1c7d38a7273f58a1180823d48725 Mon Sep 17 00:00:00 2001 From: Han Date: Sun, 25 May 2025 18:45:56 +0900 Subject: [PATCH 183/249] Update README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cefe1abb0..8257cf566 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ For [installation options see below](#installation), for usage instructions [see ## Contributing -If anything feels off, or if you feel that some functionality is missing, please check out the [contributing page][contributing]. There you will find instructions for sharing your feedback, building the tool locally, and submitting pull requests to the project. +If anything feels off or if you feel that some functionality is missing, please check out the [contributing page][contributing]. There you will find instructions for sharing your feedback, building the tool locally, and submitting pull requests to the project. If you are a hubber and are interested in shipping new commands for the CLI, check out our [doc on internal contributions][intake-doc]. @@ -58,7 +58,7 @@ Additional Conda installation options available on the [gh-feedstock page](https | ----------------------------------- | ---------------- | | `curl -sS https://webi.sh/gh \| sh` | `webi gh@stable` | -For more information about the Webi installer see [its homepage](https://webinstall.dev/). +For more information about the Webi installer, see [its homepage](https://webinstall.dev/). #### Flox @@ -127,9 +127,9 @@ Download packaged binaries from the [releases page][]. #### Verification of binaries -Since version 2.50.0 `gh` has been producing [Build Provenance Attestation](https://github.blog/changelog/2024-06-25-artifact-attestations-is-generally-available/) enabling a cryptographically verifiable paper-trail back to the origin GitHub repository, git revision and build instructions used. The build provenance attestations are signed and relies on Public Good [Sigstore](https://www.sigstore.dev/) for PKI. +Since version 2.50.0, `gh` has been producing [Build Provenance Attestation](https://github.blog/changelog/2024-06-25-artifact-attestations-is-generally-available/), enabling a cryptographically verifiable paper-trail back to the origin GitHub repository, git revision, and build instructions used. The build provenance attestations are signed and rely on Public Good [Sigstore](https://www.sigstore.dev/) for PKI. -There are two common ways to verify a downloaded release, depending if `gh` is already installed or not. If `gh` is installed, it's trivial to verify a new release: +There are two common ways to verify a downloaded release, depending on whether `gh` is already installed or not. If `gh` is installed, it's trivial to verify a new release: - **Option 1: Using `gh` if already installed:** From 22504bfa96de77ec654371608427a2265e32a929 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 26 May 2025 09:45:00 -0600 Subject: [PATCH 184/249] feat(update): add `updateable` build tag for update notifications --- internal/ghcmd/cmd.go | 8 -------- internal/ghcmd/update_disabled.go | 11 +++++++++++ internal/ghcmd/update_enabled.go | 11 +++++++++++ script/build.go | 4 +++- 4 files changed, 25 insertions(+), 9 deletions(-) create mode 100644 internal/ghcmd/update_disabled.go create mode 100644 internal/ghcmd/update_enabled.go diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index bd901b78e..9fc5bcefe 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -29,14 +29,6 @@ import ( "github.com/spf13/cobra" ) -// updaterEnabled is a statically linked build property set in gh formula within homebrew/homebrew-core -// used to control whether users are notified of newer GitHub CLI releases. -// This needs to be set to 'cli/cli' as it affects where update.CheckForUpdate() checks for releases. -// It is unclear whether this means that only homebrew builds will check for updates or not. -// Development builds leave this empty as impossible to determine if newer or not. -// For more information, . -var updaterEnabled = "" - type exitCode int const ( diff --git a/internal/ghcmd/update_disabled.go b/internal/ghcmd/update_disabled.go new file mode 100644 index 000000000..3fb7a7f4f --- /dev/null +++ b/internal/ghcmd/update_disabled.go @@ -0,0 +1,11 @@ +//go:build !updateable + +package ghcmd + +// updaterEnabled is a statically linked build property set in gh formula within homebrew/homebrew-core +// used to control whether users are notified of newer GitHub CLI releases. +// This needs to be set to 'cli/cli' as it affects where update.CheckForUpdate() checks for releases. +// It is unclear whether this means that only homebrew builds will check for updates or not. +// Development builds leave this empty as impossible to determine if newer or not. +// For more information, . +var updaterEnabled = "" diff --git a/internal/ghcmd/update_enabled.go b/internal/ghcmd/update_enabled.go new file mode 100644 index 000000000..5a136885b --- /dev/null +++ b/internal/ghcmd/update_enabled.go @@ -0,0 +1,11 @@ +//go:build updateable + +package ghcmd + +// updaterEnabled is a statically linked build property set in gh formula within homebrew/homebrew-core +// used to control whether users are notified of newer GitHub CLI releases. +// This needs to be set to 'cli/cli' as it affects where update.CheckForUpdate() checks for releases. +// It is unclear whether this means that only homebrew builds will check for updates or not. +// Development builds leave this empty as impossible to determine if newer or not. +// For more information, . +var updaterEnabled = "cli/cli" diff --git a/script/build.go b/script/build.go index d7085f590..08a193ffc 100644 --- a/script/build.go +++ b/script/build.go @@ -53,7 +53,9 @@ var tasks = map[string]func(string) error{ ldflags = fmt.Sprintf("-X github.com/cli/cli/v2/internal/authflow.oauthClientID=%s %s", os.Getenv("GH_OAUTH_CLIENT_ID"), ldflags) } - return run("go", "build", "-trimpath", "-ldflags", ldflags, "-o", exe, "./cmd/gh") + buildTags, _ := os.LookupEnv("GO_BUILDTAGS") + + return run("go", "build", "-trimpath", "-tags", buildTags, "-ldflags", ldflags, "-o", exe, "./cmd/gh") }, "manpages": func(_ string) error { return run("go", "run", "./cmd/gen-docs", "--man-page", "--doc-path", "./share/man/man1/") From 1ebed2678ad621054727cd0088945f72c38de7a3 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 27 May 2025 09:27:12 -0600 Subject: [PATCH 185/249] update sigstore-go to v1 Signed-off-by: Meredith Lancaster --- go.mod | 37 +++++------ go.sum | 190 +++++++++++++++++++++++++++++---------------------------- 2 files changed, 115 insertions(+), 112 deletions(-) diff --git a/go.mod b/go.mod index 3d572a349..108c5722d 100644 --- a/go.mod +++ b/go.mod @@ -44,17 +44,17 @@ 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.2 + github.com/sigstore/sigstore-go v1.0.0 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 github.com/yuin/goldmark v1.7.8 github.com/zalando/go-keyring v0.2.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.1 + golang.org/x/crypto v0.38.0 + golang.org/x/sync v0.14.0 + golang.org/x/term v0.32.0 + golang.org/x/text v0.25.0 + google.golang.org/grpc v1.72.0 google.golang.org/protobuf v1.36.6 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 @@ -73,6 +73,7 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/catppuccin/go v0.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.2 // 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 @@ -155,9 +156,9 @@ require ( 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.9 // indirect - github.com/sigstore/sigstore v1.9.1 // indirect - github.com/sigstore/timestamp-authority v1.2.5 // indirect + github.com/sigstore/rekor v1.3.10 // indirect + github.com/sigstore/sigstore v1.9.4 // indirect + github.com/sigstore/timestamp-authority v1.2.7 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect @@ -166,7 +167,7 @@ require ( 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 - github.com/theupdateframework/go-tuf/v2 v2.0.2 // indirect + github.com/theupdateframework/go-tuf/v2 v2.1.1 // indirect github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect @@ -175,17 +176,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.34.0 // indirect - go.opentelemetry.io/otel/metric v1.34.0 // indirect - go.opentelemetry.io/otel/trace v1.34.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.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/exp v0.0.0-20240531132922-fd00a4e0eefc // indirect golang.org/x/mod v0.24.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sys v0.32.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sys v0.33.0 // indirect golang.org/x/tools v0.29.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 + google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect k8s.io/klog/v2 v2.130.1 // indirect ) diff --git a/go.sum b/go.sum index 0b00af3b2..8d50af689 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,17 @@ -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 v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA= +cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q= +cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU= +cloud.google.com/go/auth v0.16.0/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= 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.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= +cloud.google.com/go/iam v1.5.0 h1:QlLcVMhbLGOjRcGe6VTGGTyQib8dRLK2B/kYNV0+2xs= +cloud.google.com/go/iam v1.5.0/go.mod h1:U+DOtKQltF/LxPEtcDLoobcsZMilSRwR7mgNL7knOpo= +cloud.google.com/go/kms v1.21.2 h1:c/PRUSMNQ8zXrc1sdAUnsenWWaNXN+PzTXfXOcSFdoE= +cloud.google.com/go/kms v1.21.2/go.mod h1:8wkMtHV/9Z8mLXEXr1GK7xPSBdi6knuLXIhqjuWcI6w= +cloud.google.com/go/longrunning v0.6.6 h1:XJNDo5MUfMM05xK3ewpbSdmt7R2Zw+aQEMbdQR65Rbw= +cloud.google.com/go/longrunning v0.6.6/go.mod h1:hyeGJUrPHcx0u2Uu1UFSoYZLn4lkMrccJig0t4FI7yw= 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= @@ -20,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.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/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 h1:OVoM452qUFBrX+URdH3VpR299ma4kfom0yB0URYky9g= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0/go.mod h1:kUjrAo8bgEwLeZ/CmHqNl3Z/kPm7y6FKfxxK0izYUg4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= 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/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/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= @@ -58,10 +58,10 @@ 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/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= +github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= 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= @@ -74,14 +74,14 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b 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/aws-sdk-go-v2/service/kms v1.38.3 h1:RivOtUH3eEu6SWnUMFHKAW4MqDOzWn1vGQ3S38Y5QMg= +github.com/aws/aws-sdk-go-v2/service/kms v1.38.3/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/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= @@ -100,6 +100,8 @@ 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/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= +github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= 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.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= @@ -227,8 +229,8 @@ github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZ 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-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= +github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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= @@ -253,8 +255,6 @@ 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= -github.com/google/tink/go v1.7.0/go.mod h1:GAUOd+QE3pgj9q8VKIGTCP33c/B7eb4NhxLcgTJZStM= 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= @@ -415,8 +415,8 @@ 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.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= -github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 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.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= @@ -457,22 +457,22 @@ github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZV github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= 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.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= -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/sigstore/rekor v1.3.10 h1:/mSvRo4MZ/59ECIlARhyykAlQlkmeAQpvBPlmJtZOCU= +github.com/sigstore/rekor v1.3.10/go.mod h1:JvryKJ40O0XA48MdzYUPu0y4fyvqt0C4iSY7ri9iu3A= +github.com/sigstore/sigstore v1.9.4 h1:64+OGed80+A4mRlNzRd055vFcgBeDghjZw24rPLZgDU= +github.com/sigstore/sigstore v1.9.4/go.mod h1:Q7tGTC3gbtK7c3jcxEmGc2MmK4rRpIRzi3bxRFWKvEY= +github.com/sigstore/sigstore-go v1.0.0 h1:4N07S2zLxf09nTRwaPKyAxbKzpM8WJYUS8lWWaYxneU= +github.com/sigstore/sigstore-go v1.0.0/go.mod h1:UYsZ/XHE4eltv1o1Lu+n6poW1Z5to3f0+emvfXNxIN8= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.4 h1:kQqUJ1VuWdJltMkinFXAHTlJrzMRPoNgL+dy6WyJ/dA= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.4/go.mod h1:9miLz7c69vj/7VH7UpCKHDia41HCTIDJWJWf4Ex5yUk= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.4 h1:MHRm7YQuF4zFyoXRLgUdLaNxqVO6JlLGnkDUI9fm9ow= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.4/go.mod h1:899VNYSSnQ0QtcuhkW0gznzxn0cqhowTL3nzc/xnym8= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.4 h1:C2nSyTmTxpuamUmLCWWZwz+0Y1IQIig9XwAJ4UAn/SI= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.4/go.mod h1:vjDahU0sEw/WMkKkygZNH72EMg86iaFNLAaJFXhItXU= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.4 h1:t9yfb6yteIDv8CNRT6OHdqgTV6TSj+CdOtZP9dVhpsQ= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.4/go.mod h1:m7sQxVJmDa+rsmS1m6biQxaLX83pzNS7ThUEyjOqkCU= +github.com/sigstore/timestamp-authority v1.2.7 h1:HP/VT4wnL4uzP0fVo3eHXlt0reuNgW3PLt78+BV0I5I= +github.com/sigstore/timestamp-authority v1.2.7/go.mod h1:te4ThQ3Q/CX1bzVsf5mMN0K7Z/cgc2OcoEGxAJiFqqI= 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= @@ -499,16 +499,18 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= -github.com/theupdateframework/go-tuf/v2 v2.0.2 h1:PyNnjV9BJNzN1ZE6BcWK+5JbF+if370jjzO84SS+Ebo= -github.com/theupdateframework/go-tuf/v2 v2.0.2/go.mod h1:baB22nBHeHBCeuGZcIlctNq4P61PcOdyARlplg5xmLA= +github.com/theupdateframework/go-tuf/v2 v2.1.1 h1:OWcoHItwsGO+7m0wLa7FDWPR4oB1cj0zOr1kosE4G+I= +github.com/theupdateframework/go-tuf/v2 v2.1.1/go.mod h1:V675cQGhZONR0OGQ8r1feO0uwtsTBYPDWHzAAPn5rjE= 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/tink-crypto/tink-go-hcvault/v2 v2.3.0 h1:6nAX1aRGnkg2SEUMwO5toB2tQkP0Jd6cbmZ/K5Le1V0= +github.com/tink-crypto/tink-go-hcvault/v2 v2.3.0/go.mod h1:HOC5NWW1wBI2Vke1FGcRBvDATkEYE7AUDiYbXqi2sBw= +github.com/tink-crypto/tink-go/v2 v2.4.0 h1:8VPZeZI4EeZ8P/vB6SIkhlStrJfivTJn+cQ4dtyHNh0= +github.com/tink-crypto/tink-go/v2 v2.4.0/go.mod h1:l//evrF2Y3MjdbpNDNGnKgCpo5zSmvUvnQ4MU+yE2sw= 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= @@ -529,22 +531,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.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/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= 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.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.step.sm/crypto v0.63.0 h1:U1QGELQqJ85oDfeNFE2V52cow1rvy0m3MekG3wFmyXY= +go.step.sm/crypto v0.63.0/go.mod h1:aj3LETmCZeSil1DMq3BlbhDBcN86+mmKrHZtXWyc0L4= 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= @@ -553,24 +555,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.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/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc h1:O9NuF4s+E/PvMIy+9IUZB9znFwUIXEWSstNjek6VpVg= +golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -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/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= +golang.org/x/oauth2 v0.29.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.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.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= @@ -581,19 +583,19 @@ 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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.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.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 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= @@ -602,16 +604,16 @@ 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.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc= -google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ= +google.golang.org/api v0.230.0 h1:2u1hni3E+UXAXrONrrkfWpi/V6cyKVAbfGVeGtC3OxM= +google.golang.org/api v0.230.0/go.mod h1:aqvtoMk7YkiXx+6U12arQFExiRV9D/ekvMCwCd/TksQ= 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.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= -google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1:UdXH7Kzbj+Vzastr5nVfccbmFsmYNygVLSPk1pEfDoY= +google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= +google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= 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= From dcca4b2940657dc044302ecc158c14364e30fe99 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 27 May 2025 09:34:35 -0600 Subject: [PATCH 186/249] replace deprecated type Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verification/sigstore.go | 22 ++++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 190ea5c0f..995771d55 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -46,9 +46,9 @@ type SigstoreVerifier interface { type LiveSigstoreVerifier struct { Logger *io.Handler NoPublicGood bool - PublicGood *verify.SignedEntityVerifier - GitHub *verify.SignedEntityVerifier - Custom map[string]*verify.SignedEntityVerifier + PublicGood *verify.Verifier + GitHub *verify.Verifier + Custom map[string]*verify.Verifier } var ErrNoAttestationsVerified = errors.New("no attestations were verified") @@ -86,13 +86,13 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro return liveVerifier, nil } -func createCustomVerifiers(trustedRoot string, noPublicGood bool) (map[string]*verify.SignedEntityVerifier, error) { +func createCustomVerifiers(trustedRoot string, noPublicGood bool) (map[string]*verify.Verifier, error) { 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) + verifiers := make(map[string]*verify.Verifier) reader := bufio.NewReader(bytes.NewReader(customTrustRoots)) var line []byte var readError error @@ -189,7 +189,7 @@ func getBundleIssuer(b *bundle.Bundle) (string, error) { return leafCert.Issuer.Organization[0], nil } -func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) { +func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.Verifier, 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.Custom != nil { @@ -291,7 +291,7 @@ func (v *LiveSigstoreVerifier) Verify(attestations []*api.Attestation, policy ve return results, nil } -func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) { +func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.Verifier, error) { // All we know about this trust root is its configuration so make some // educated guesses as to what the policy should be. verifierConfig := []verify.VerifierOption{} @@ -314,7 +314,7 @@ func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerif return gv, nil } -func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string]) (*verify.SignedEntityVerifier, error) { +func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string]) (*verify.Verifier, error) { var tr string opts := GitHubTUFOptions(tufMetadataDir) @@ -339,7 +339,7 @@ func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string]) (*ve return newGitHubVerifierWithTrustedRoot(trustedRoot) } -func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) { +func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.Verifier, error) { gv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedTimestamps(1)) if err != nil { return nil, fmt.Errorf("failed to create GitHub verifier: %v", err) @@ -348,7 +348,7 @@ func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.Si return gv, nil } -func newPublicGoodVerifier(tufMetadataDir o.Option[string]) (*verify.SignedEntityVerifier, error) { +func newPublicGoodVerifier(tufMetadataDir o.Option[string]) (*verify.Verifier, error) { opts := DefaultOptionsWithCacheSetting(tufMetadataDir) client, err := tuf.New(opts) if err != nil { @@ -362,7 +362,7 @@ func newPublicGoodVerifier(tufMetadataDir o.Option[string]) (*verify.SignedEntit return newPublicGoodVerifierWithTrustedRoot(trustedRoot) } -func newPublicGoodVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) { +func newPublicGoodVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.Verifier, error) { sv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedCertificateTimestamps(1), verify.WithTransparencyLog(1), verify.WithObserverTimestamps(1)) if err != nil { return nil, fmt.Errorf("failed to create Public Good verifier: %v", err) From a154ff5cfa220ced0426115909e783b584f43190 Mon Sep 17 00:00:00 2001 From: Meredith Lancaster Date: Tue, 27 May 2025 09:38:46 -0600 Subject: [PATCH 187/249] replace deprecated func Signed-off-by: Meredith Lancaster --- pkg/cmd/attestation/verification/sigstore.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index 995771d55..a8a76e234 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -306,7 +306,7 @@ func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.Verifier, error) verifierConfig = append(verifierConfig, verify.WithTransparencyLog(1)) } - gv, err := verify.NewSignedEntityVerifier(trustedRoot, verifierConfig...) + gv, err := verify.NewVerifier(trustedRoot, verifierConfig...) if err != nil { return nil, fmt.Errorf("failed to create custom verifier: %v", err) } @@ -340,7 +340,7 @@ func newGitHubVerifier(trustDomain string, tufMetadataDir o.Option[string]) (*ve } func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.Verifier, error) { - gv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedTimestamps(1)) + gv, err := verify.NewVerifier(trustedRoot, verify.WithSignedTimestamps(1)) if err != nil { return nil, fmt.Errorf("failed to create GitHub verifier: %v", err) } @@ -363,7 +363,7 @@ func newPublicGoodVerifier(tufMetadataDir o.Option[string]) (*verify.Verifier, e } func newPublicGoodVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.Verifier, error) { - sv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedCertificateTimestamps(1), verify.WithTransparencyLog(1), verify.WithObserverTimestamps(1)) + sv, err := verify.NewVerifier(trustedRoot, verify.WithSignedCertificateTimestamps(1), verify.WithTransparencyLog(1), verify.WithObserverTimestamps(1)) if err != nil { return nil, fmt.Errorf("failed to create Public Good verifier: %v", err) } From ab49b2abbc50c6c934f00a1bb56b26e44fffcc40 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Tue, 27 May 2025 12:02:24 -0400 Subject: [PATCH 188/249] remove filepath test --- pkg/cmd/release/attestation/options_test.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/pkg/cmd/release/attestation/options_test.go b/pkg/cmd/release/attestation/options_test.go index 00bba29a5..89d260199 100644 --- a/pkg/cmd/release/attestation/options_test.go +++ b/pkg/cmd/release/attestation/options_test.go @@ -5,17 +5,6 @@ import ( "testing" ) -func TestAttestOptions_Clean(t *testing.T) { - opts := &AttestOptions{ - AssetFilePath: "foo/bar/../baz.txt", - } - opts.Clean() - expected := "foo/baz.txt" - if opts.AssetFilePath != expected && opts.AssetFilePath != "./foo/baz.txt" { // OS differences - t.Errorf("expected AssetFilePath to be cleaned to %q, got %q", expected, opts.AssetFilePath) - } -} - func TestAttestOptions_AreFlagsValid_Valid(t *testing.T) { opts := &AttestOptions{ Repo: "owner/repo", From 3e3b9adb455fe1aaac8b9ee8826c74726389e75d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 27 May 2025 10:11:03 -0600 Subject: [PATCH 189/249] Refactor build tag logic Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- script/build.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/script/build.go b/script/build.go index 08a193ffc..2ea0088af 100644 --- a/script/build.go +++ b/script/build.go @@ -55,7 +55,13 @@ var tasks = map[string]func(string) error{ buildTags, _ := os.LookupEnv("GO_BUILDTAGS") - return run("go", "build", "-trimpath", "-tags", buildTags, "-ldflags", ldflags, "-o", exe, "./cmd/gh") + args := []string{"go", "build", "-trimpath"} + if buildTags != "" { + args = append(args, "-tags", buildTags) + } + args = append(args, "-ldflags", ldflags, "-o", exe, "./cmd/gh") + + return run(args...) }, "manpages": func(_ string) error { return run("go", "run", "./cmd/gen-docs", "--man-page", "--doc-path", "./share/man/man1/") From dd4095d0163a8218d3fb6e37235f5eb7ece434fe Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 27 May 2025 13:05:47 -0600 Subject: [PATCH 190/249] doc(update): improve comments on updaterEnabled --- internal/ghcmd/update_disabled.go | 7 +------ internal/ghcmd/update_enabled.go | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/internal/ghcmd/update_disabled.go b/internal/ghcmd/update_disabled.go index 3fb7a7f4f..3d9fa4e57 100644 --- a/internal/ghcmd/update_disabled.go +++ b/internal/ghcmd/update_disabled.go @@ -2,10 +2,5 @@ package ghcmd -// updaterEnabled is a statically linked build property set in gh formula within homebrew/homebrew-core -// used to control whether users are notified of newer GitHub CLI releases. -// This needs to be set to 'cli/cli' as it affects where update.CheckForUpdate() checks for releases. -// It is unclear whether this means that only homebrew builds will check for updates or not. -// Development builds leave this empty as impossible to determine if newer or not. -// For more information, . +// See update_enabled.go comment for more information. var updaterEnabled = "" diff --git a/internal/ghcmd/update_enabled.go b/internal/ghcmd/update_enabled.go index 5a136885b..3eb9eba4f 100644 --- a/internal/ghcmd/update_enabled.go +++ b/internal/ghcmd/update_enabled.go @@ -2,10 +2,17 @@ package ghcmd -// updaterEnabled is a statically linked build property set in gh formula within homebrew/homebrew-core -// used to control whether users are notified of newer GitHub CLI releases. -// This needs to be set to 'cli/cli' as it affects where update.CheckForUpdate() checks for releases. -// It is unclear whether this means that only homebrew builds will check for updates or not. -// Development builds leave this empty as impossible to determine if newer or not. -// For more information, . +// `updateable` is a build tag set in the gh formula within homebrew/homebrew-core +// and is used to control whether users are notified of newer GitHub CLI releases. +// +// Currently, updaterEnabled needs to be set to 'cli/cli' as it affects where +// update.CheckForUpdate() checks for releases. It is unclear to what extent +// this updaterEnabled is being used by unofficial forks or builds, so we decided +// to leave it available for injection as a string variable for now. +// +// Development builds do not generate update messages by default. +// +// For more information, see: +// - the Homebrew formula for gh: . +// - a discussion about adding this build tag: . var updaterEnabled = "cli/cli" From bc93b5413735238f5cc4be18710860dc211abe28 Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Tue, 27 May 2025 09:23:20 -0700 Subject: [PATCH 191/249] add Digest to ReleaseAsset struct Signed-off-by: Brian DeHamer --- pkg/cmd/release/shared/fetch.go | 2 ++ pkg/cmd/release/view/view.go | 5 +++++ pkg/cmd/release/view/view_test.go | 12 ++++++------ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 8db7e502a..4c0a014b9 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -71,6 +71,7 @@ type ReleaseAsset struct { Name string Label string Size int64 + Digest *string State string APIURL string `json:"url"` @@ -107,6 +108,7 @@ func (rel *Release) ExportData(fields []string) map[string]interface{} { "name": a.Name, "label": a.Label, "size": a.Size, + "digest": a.Digest, "state": a.State, "createdAt": a.CreatedAt, "updatedAt": a.UpdatedAt, diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index c9030f299..1b8fe0b0f 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -158,6 +158,11 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { table := tableprinter.New(io, tableprinter.NoHeader) for _, a := range release.Assets { table.AddField(a.Name) + if a.Digest == nil { + table.AddField("") + } else { + table.AddField(*a.Digest) + } table.AddField(humanFileSize(a.Size)) table.EndRow() } diff --git a/pkg/cmd/release/view/view_test.go b/pkg/cmd/release/view/view_test.go index 8ca4f14c8..f3fb54ce1 100644 --- a/pkg/cmd/release/view/view_test.go +++ b/pkg/cmd/release/view/view_test.go @@ -150,8 +150,8 @@ func Test_viewRun(t *testing.T) { Assets - windows.zip 12 B - linux.tgz 34 B + windows.zip sha256:deadc0de 12 B + linux.tgz 34 B View on GitHub: https://github.com/OWNER/REPO/releases/tags/v1.2.3 `), @@ -174,8 +174,8 @@ func Test_viewRun(t *testing.T) { Assets - windows.zip 12 B - linux.tgz 34 B + windows.zip sha256:deadc0de 12 B + linux.tgz 34 B View on GitHub: https://github.com/OWNER/REPO/releases/tags/v1.2.3 `), @@ -248,8 +248,8 @@ func Test_viewRun(t *testing.T) { "published_at": "%[1]s", "html_url": "https://github.com/OWNER/REPO/releases/tags/v1.2.3", "assets": [ - { "name": "windows.zip", "size": 12 }, - { "name": "linux.tgz", "size": 34 } + { "name": "windows.zip", "size": 12, "digest": "sha256:deadc0de" }, + { "name": "linux.tgz", "size": 34, "digest": null } ] }`, tt.releasedAt.Format(time.RFC3339), tt.releaseBody)) From aa64bb5708673a57813135957075a754ee2e4c5e Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 28 May 2025 12:55:11 +0100 Subject: [PATCH 192/249] docs(pr list): mention `--head` does not support `:` syntax Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/list/list.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 8837c66fb..7188df1a5 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -105,7 +105,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().IntVarP(&opts.LimitResults, "limit", "L", 30, "Maximum number of items to fetch") cmdutil.StringEnumFlag(cmd, &opts.State, "state", "s", "open", []string{"open", "closed", "merged", "all"}, "Filter by state") cmd.Flags().StringVarP(&opts.BaseBranch, "base", "B", "", "Filter by base branch") - cmd.Flags().StringVarP(&opts.HeadBranch, "head", "H", "", "Filter by head branch") + cmd.Flags().StringVarP(&opts.HeadBranch, "head", "H", "", `Filter by head branch (":" syntax not supported)`) cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Filter by label") cmd.Flags().StringVarP(&opts.Author, "author", "A", "", "Filter by author") cmd.Flags().StringVar(&appAuthor, "app", "", "Filter by GitHub App author") From 19e1178a32fd5e06469386cb42eedf6d0991efaa Mon Sep 17 00:00:00 2001 From: phanium <91544758+phanen@users.noreply.github.com> Date: Wed, 28 May 2025 21:40:14 +0800 Subject: [PATCH 193/249] fix: `gh gist edit` panic when no file in a gist (#10627) * fix: `gh gist edit` panic when no file in a gist * fix: improve error message Signed-off-by: Babak K. Shandiz --------- Signed-off-by: Babak K. Shandiz Co-authored-by: Babak K. Shandiz --- pkg/cmd/gist/edit/edit.go | 2 ++ pkg/cmd/gist/edit/edit_test.go | 24 ++++++++++++++++++++++++ pkg/cmd/gist/shared/shared.go | 3 +++ pkg/cmd/gist/shared/shared_test.go | 27 +++++++++++++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/pkg/cmd/gist/edit/edit.go b/pkg/cmd/gist/edit/edit.go index e0d6e3b84..9e74adf62 100644 --- a/pkg/cmd/gist/edit/edit.go +++ b/pkg/cmd/gist/edit/edit.go @@ -236,6 +236,8 @@ func editRun(opts *EditOptions) error { if filename == "" { if len(candidates) == 1 { filename = candidates[0] + } else if len(candidates) == 0 { + return errors.New("no file in the gist") } else { if !opts.IO.CanPrompt() { return errors.New("unsure what file to edit; either specify --filename or run interactively") diff --git a/pkg/cmd/gist/edit/edit_test.go b/pkg/cmd/gist/edit/edit_test.go index 728f6e894..12cdf8169 100644 --- a/pkg/cmd/gist/edit/edit_test.go +++ b/pkg/cmd/gist/edit/edit_test.go @@ -557,6 +557,30 @@ func Test_editRun(t *testing.T) { }, wantErr: "gist ID or URL required when not running interactively", }, + { + name: "edit no-file gist (#10626)", + opts: &EditOptions{ + Selector: "1234", + }, + mockGist: &shared.Gist{ + ID: "1234", + Files: map[string]*shared.GistFile{}, + Owner: &shared.GistOwner{Login: "octocat"}, + }, + wantErr: "no file in the gist", + }, + { + name: "edit no-file gist, nil map (#10626)", + opts: &EditOptions{ + Selector: "1234", + }, + mockGist: &shared.Gist{ + ID: "1234", + Files: nil, + Owner: &shared.GistOwner{Login: "octocat"}, + }, + wantErr: "no file in the gist", + }, } for _, tt := range tests { diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go index fc63f56ce..99a5524ee 100644 --- a/pkg/cmd/gist/shared/shared.go +++ b/pkg/cmd/gist/shared/shared.go @@ -44,6 +44,9 @@ func (g Gist) Filename() string { for fn := range g.Files { filenames = append(filenames, fn) } + if len(filenames) == 0 { + return "" + } sort.Strings(filenames) return filenames[0] } diff --git a/pkg/cmd/gist/shared/shared_test.go b/pkg/cmd/gist/shared/shared_test.go index 15b14a939..0bc1e1f11 100644 --- a/pkg/cmd/gist/shared/shared_test.go +++ b/pkg/cmd/gist/shared/shared_test.go @@ -163,6 +163,33 @@ func TestPromptGists(t *testing.T) { response: `{ "data": { "viewer": { "gists": { "nodes": [] } } } }`, wantOut: Gist{}, }, + { + name: "prompt list contains no-file gist (#10626)", + prompterStubs: func(pm *prompter.MockPrompter) { + pm.RegisterSelect("Select a gist", + []string{" about 6 hours ago", "gistfile0.txt about 6 hours ago"}, + func(_, _ string, opts []string) (int, error) { + return prompter.IndexFor(opts, " about 6 hours ago") + }) + }, + response: `{ "data": { "viewer": { "gists": { "nodes": [ + { + "name": "1234", + "files": [], + "description": "", + "updatedAt": "%[1]v", + "isPublic": true + }, + { + "name": "5678", + "files": [{ "name": "gistfile0.txt" }], + "description": "", + "updatedAt": "%[1]v", + "isPublic": true + } + ] } } } }`, + wantOut: Gist{ID: "1234", Files: map[string]*GistFile{}, UpdatedAt: sixHoursAgo, Public: true}, + }, } ios, _, _, _ := iostreams.Test() From f8e092cbb7bb83cf7dc8620f4875142dc7a69c12 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 May 2025 14:26:37 +0000 Subject: [PATCH 194/249] chore(deps): bump github.com/yuin/goldmark from 1.7.8 to 1.7.12 Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.8 to 1.7.12. - [Release notes](https://github.com/yuin/goldmark/releases) - [Commits](https://github.com/yuin/goldmark/compare/v1.7.8...v1.7.12) --- updated-dependencies: - dependency-name: github.com/yuin/goldmark dependency-version: 1.7.12 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 108c5722d..e527f0446 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 - github.com/yuin/goldmark v1.7.8 + github.com/yuin/goldmark v1.7.12 github.com/zalando/go-keyring v0.2.5 golang.org/x/crypto v0.38.0 golang.org/x/sync v0.14.0 diff --git a/go.sum b/go.sum index 8d50af689..81f17a75c 100644 --- a/go.sum +++ b/go.sum @@ -521,8 +521,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= -github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= +github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= From ed57df1a9af6c614b6396abbcf85b6a0aa0a35a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 May 2025 14:26:44 +0000 Subject: [PATCH 195/249] chore(deps): bump google.golang.org/grpc from 1.72.0 to 1.72.2 Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.72.0 to 1.72.2. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.72.0...v1.72.2) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.72.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 108c5722d..62aa7437d 100644 --- a/go.mod +++ b/go.mod @@ -54,7 +54,7 @@ require ( golang.org/x/sync v0.14.0 golang.org/x/term v0.32.0 golang.org/x/text v0.25.0 - google.golang.org/grpc v1.72.0 + google.golang.org/grpc v1.72.2 google.golang.org/protobuf v1.36.6 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 8d50af689..8d55e736d 100644 --- a/go.sum +++ b/go.sum @@ -612,8 +612,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1: google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA= google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM= -google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= +google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= 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= From 8fcb206de4731bb42de81d6dc563895f71ab7efb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 28 May 2025 15:47:41 +0000 Subject: [PATCH 196/249] chore(deps): bump github.com/sigstore/protobuf-specs from 0.4.1 to 0.4.2 Bumps [github.com/sigstore/protobuf-specs](https://github.com/sigstore/protobuf-specs) from 0.4.1 to 0.4.2. - [Release notes](https://github.com/sigstore/protobuf-specs/releases) - [Changelog](https://github.com/sigstore/protobuf-specs/blob/main/CHANGELOG.md) - [Commits](https://github.com/sigstore/protobuf-specs/compare/v0.4.1...v0.4.2) --- updated-dependencies: - dependency-name: github.com/sigstore/protobuf-specs dependency-version: 0.4.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 108c5722d..a8cdad760 100644 --- a/go.mod +++ b/go.mod @@ -43,7 +43,7 @@ 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.4.1 + github.com/sigstore/protobuf-specs v0.4.2 github.com/sigstore/sigstore-go v1.0.0 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 diff --git a/go.sum b/go.sum index 8d50af689..3063ddf4c 100644 --- a/go.sum +++ b/go.sum @@ -455,8 +455,8 @@ 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.4.1 h1:5SsMqZbdkcO/DNHudaxuCUEjj6x29tS2Xby1BxGU7Zc= -github.com/sigstore/protobuf-specs v0.4.1/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= +github.com/sigstore/protobuf-specs v0.4.2 h1:bD5bnhctpGNiR+FAEZl7N95XkN8TJFrNMIcWLunDtxA= +github.com/sigstore/protobuf-specs v0.4.2/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= github.com/sigstore/rekor v1.3.10 h1:/mSvRo4MZ/59ECIlARhyykAlQlkmeAQpvBPlmJtZOCU= github.com/sigstore/rekor v1.3.10/go.mod h1:JvryKJ40O0XA48MdzYUPu0y4fyvqt0C4iSY7ri9iu3A= github.com/sigstore/sigstore v1.9.4 h1:64+OGed80+A4mRlNzRd055vFcgBeDghjZw24rPLZgDU= From 7b33da981e6e3d2236cde1e0bb78c8d3d7596e5d Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Thu, 29 May 2025 11:53:33 -0700 Subject: [PATCH 197/249] Update pkg/cmd/release/view/view.go Co-authored-by: Andy Feller --- pkg/cmd/release/view/view.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index 1b8fe0b0f..3578e7325 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -155,7 +155,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { if len(release.Assets) > 0 { fmt.Fprintln(w, cs.Bold("Assets")) //nolint:staticcheck // SA1019: Showing NAME|SIZE headers adds nothing to table. - table := tableprinter.New(io, tableprinter.NoHeader) + table := tableprinter.New(io, tableprinter.WithHeader("Name", "Digest", "Size")) for _, a := range release.Assets { table.AddField(a.Name) if a.Digest == nil { From 0d6b9f89a4c20632e060a5e6f425cf820d6c16a6 Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Thu, 29 May 2025 11:54:44 -0700 Subject: [PATCH 198/249] remove nolint comment for release header Signed-off-by: Brian DeHamer --- pkg/cmd/release/view/view.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index 3578e7325..59a19b4e5 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -154,7 +154,6 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { if len(release.Assets) > 0 { fmt.Fprintln(w, cs.Bold("Assets")) - //nolint:staticcheck // SA1019: Showing NAME|SIZE headers adds nothing to table. table := tableprinter.New(io, tableprinter.WithHeader("Name", "Digest", "Size")) for _, a := range release.Assets { table.AddField(a.Name) From fc6ac59bbce129ce3bf05b4f4f069106af912b72 Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Thu, 29 May 2025 12:00:40 -0700 Subject: [PATCH 199/249] fixup release asset digest tests Signed-off-by: Brian DeHamer --- pkg/cmd/release/view/view_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cmd/release/view/view_test.go b/pkg/cmd/release/view/view_test.go index f3fb54ce1..be345b186 100644 --- a/pkg/cmd/release/view/view_test.go +++ b/pkg/cmd/release/view/view_test.go @@ -150,6 +150,7 @@ func Test_viewRun(t *testing.T) { Assets + NAME DIGEST SIZE windows.zip sha256:deadc0de 12 B linux.tgz 34 B @@ -174,6 +175,7 @@ func Test_viewRun(t *testing.T) { Assets + NAME DIGEST SIZE windows.zip sha256:deadc0de 12 B linux.tgz 34 B From 6cfe8ca0e216ae1ff08d9b5ba7f51fcc18f25077 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 29 May 2025 13:56:54 -0600 Subject: [PATCH 200/249] Update `go-gh` to v2.12.1 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 2c9b32aa7..a4c973df1 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 github.com/charmbracelet/huh v0.7.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc - github.com/cli/go-gh/v2 v2.12.0 + github.com/cli/go-gh/v2 v2.12.1 github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 github.com/cli/oauth v1.1.1 github.com/cli/safeexec v1.0.1 diff --git a/go.sum b/go.sum index 270ede090..718e0ca67 100644 --- a/go.sum +++ b/go.sum @@ -137,8 +137,8 @@ github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJn github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= -github.com/cli/go-gh/v2 v2.12.0 h1:PIurZ13fXbWDbr2//6ws4g4zDbryO+iDuTpiHgiV+6k= -github.com/cli/go-gh/v2 v2.12.0/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw= +github.com/cli/go-gh/v2 v2.12.1 h1:SVt1/afj5FRAythyMV3WJKaUfDNsxXTIe7arZbwTWKA= +github.com/cli/go-gh/v2 v2.12.1/go.mod h1:+5aXmEOJsH9fc9mBHfincDwnS02j2AIA/DsTH0Bk5uw= github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 h1:QDrhR4JA2n3ij9YQN0u5ZeuvRIIvsUGmf5yPlTS0w8E= github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24/go.mod h1:rr9GNING0onuVw8MnracQHn7PcchnFlP882Y0II2KZk= github.com/cli/oauth v1.1.1 h1:459gD3hSjlKX9B1uXBuiAMdpXBUQ9QGf/NDcCpoQxPs= From 71c2361dfca1ba083eec3c5d24df5970bf379123 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 08:17:21 -0700 Subject: [PATCH 201/249] add unit test --- go.mod | 1 + go.sum | 6 + pkg/cmd/attestation/api/mock_client.go | 11 ++ pkg/cmd/attestation/test/data/a.zip | 28 ++++ pkg/cmd/attestation/test/data/data.go | 12 ++ .../test/data/github_release_bundle.json | 24 +++ pkg/cmd/release/shared/fetch.go | 8 + pkg/cmd/release/verify-asset/verify-asset.go | 28 +++- .../release/verify-asset/verify-asset_test.go | 158 ++++++++++++++++++ pkg/cmd/release/verify/verify.go | 16 +- pkg/cmd/release/verify/verify_test.go | 142 ++++++++++++++++ 11 files changed, 421 insertions(+), 13 deletions(-) create mode 100644 pkg/cmd/attestation/test/data/a.zip create mode 100644 pkg/cmd/attestation/test/data/github_release_bundle.json create mode 100644 pkg/cmd/release/verify-asset/verify-asset_test.go create mode 100644 pkg/cmd/release/verify/verify_test.go diff --git a/go.mod b/go.mod index f95c8a7c2..7ffaf3cc9 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,7 @@ require ( google.golang.org/protobuf v1.36.6 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 + gotest.tools/v3 v3.0.3 ) require ( diff --git a/go.sum b/go.sum index e0ecad6a7..564042eaf 100644 --- a/go.sum +++ b/go.sum @@ -243,6 +243,7 @@ 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.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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= @@ -410,6 +411,7 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH 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.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -483,6 +485,7 @@ 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.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 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.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= @@ -560,6 +563,7 @@ golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCR golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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= @@ -597,11 +601,13 @@ 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-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 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= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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= diff --git a/pkg/cmd/attestation/api/mock_client.go b/pkg/cmd/attestation/api/mock_client.go index b6062b39f..4b4f06eff 100644 --- a/pkg/cmd/attestation/api/mock_client.go +++ b/pkg/cmd/attestation/api/mock_client.go @@ -6,6 +6,13 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/test/data" ) +func makeTestReleaseAttestation() Attestation { + return Attestation{ + Bundle: data.GitHubReleaseBundle(nil), + BundleURL: "https://example.com", + } +} + func makeTestAttestation() Attestation { return Attestation{Bundle: data.SigstoreBundle(nil), BundleURL: "https://example.com"} } @@ -26,8 +33,12 @@ func (m MockClient) GetTrustDomain() (string, error) { func OnGetByDigestSuccess(params FetchParams) ([]*Attestation, error) { att1 := makeTestAttestation() att2 := makeTestAttestation() + att3 := makeTestReleaseAttestation() attestations := []*Attestation{&att1, &att2} if params.PredicateType != "" { + if params.PredicateType == "https://in-toto.io/attestation/release/v0.1" { + attestations = append(attestations, &att3) + } return FilterAttestations(params.PredicateType, attestations) } diff --git a/pkg/cmd/attestation/test/data/a.zip b/pkg/cmd/attestation/test/data/a.zip new file mode 100644 index 000000000..f4595ef44 --- /dev/null +++ b/pkg/cmd/attestation/test/data/a.zip @@ -0,0 +1,28 @@ +a # frozen_string_literal: true + +source "https://rubygems.org" + +source "https://rubygems.pkg.github.com/github" do + gem "entitlements-aad-plugin", "~> 1.0" + gem "entitlements-app", "~> 1.2" + gem "entitlements-github-plugin", "~> 1.2" + gem "entitlements-gitrepo-auditor-plugin", "~> 1.0" + gem "entitlements-jit-github-plugin", "~> 1.0" + gem "entitlements-lib", "~> 0.2" + gem "entitlements-stafftools-plugin", "~> 1.0" +end + +group :development do + gem "base64", "~> 0.2.0" + gem "irb", "~> 1.15" + gem "pry", "~> 0.14" + gem "pry-byebug", "~> 3.9" + gem "pry-rescue", "~> 1.6" + gem "rspec", "~> 3.13" + gem "rubocop", "~> 1.71" + gem "rubocop-github", "~> 0.20.0" + gem "rubocop-performance" + gem "rubocop-rspec", "~> 3.4.0" + gem "simplecov", "~> 0.21" + gem "simplecov-erb", "~> 1.0.0" +end diff --git a/pkg/cmd/attestation/test/data/data.go b/pkg/cmd/attestation/test/data/data.go index ef3c35c20..223d6f22e 100644 --- a/pkg/cmd/attestation/test/data/data.go +++ b/pkg/cmd/attestation/test/data/data.go @@ -10,6 +10,9 @@ import ( //go:embed sigstore-js-2.1.0-bundle.json var SigstoreBundleRaw []byte +//go:embed github_release_bundle.json +var GitHubReleaseBundleRaw []byte + // SigstoreBundle returns a test sigstore-go bundle.Bundle func SigstoreBundle(t *testing.T) *bundle.Bundle { b := &bundle.Bundle{} @@ -19,3 +22,12 @@ func SigstoreBundle(t *testing.T) *bundle.Bundle { } return b } + +func GitHubReleaseBundle(t *testing.T) *bundle.Bundle { + b := &bundle.Bundle{} + err := b.UnmarshalJSON(GitHubReleaseBundleRaw) + if err != nil { + t.Fatalf("failed to unmarshal GitHub release bundle: %v", err) + } + return b +} diff --git a/pkg/cmd/attestation/test/data/github_release_bundle.json b/pkg/cmd/attestation/test/data/github_release_bundle.json new file mode 100644 index 000000000..ae8dd1b56 --- /dev/null +++ b/pkg/cmd/attestation/test/data/github_release_bundle.json @@ -0,0 +1,24 @@ +{ + "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", + "verificationMaterial": { + "timestampVerificationData": { + "rfc3161Timestamps": [ + { + "signedTimestamp": "MIIC0TADAgEAMIICyAYJKoZIhvcNAQcCoIICuTCCArUCAQMxDTALBglghkgBZQMEAgIwgbwGCyqGSIb3DQEJEAEEoIGsBIGpMIGmAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgGvFc6nUuLhnXfhM9p0DV91c5kHvafP1hs9BX8KYeeSYCFQDhjGrIIiaH/jkMdN6HUsErnUfrlRgPMjAyNTA1MTMyMzAzNTFaMAMCAQGgNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIFRpbWVzdGFtcGluZ6AAMYIB3jCCAdoCAQEwSjAyMRUwEwYDVQQKEwxHaXRIdWIsIEluYy4xGTAXBgNVBAMTEFRTQSBpbnRlcm1lZGlhdGUCFB+7MIjE5/rL4XA4fNDnmXHA04+wMAsGCWCGSAFlAwQCAqCCAQUwGgYJKoZIhvcNAQkDMQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNTA1MTMyMzAzNTFaMD8GCSqGSIb3DQEJBDEyBDDVh2oDCJy7ustugLKfVcUSNjo5M2MFMNKIU11sIQDCNOo5gbj9R97sCWXNnfmUztMwgYcGCyqGSIb3DQEJEAIvMXgwdjB0MHIEIHuISsKSyiJtlhGjT+RyS+tYQ7iwCMsMCTGmz2NK3D7DME4wNqQ0MDIxFTATBgNVBAoTDEdpdEh1YiwgSW5jLjEZMBcGA1UEAxMQVFNBIGludGVybWVkaWF0ZQIUH7swiMTn+svhcDh80OeZccDTj7AwCgYIKoZIzj0EAwMEZzBlAjAqp/fYVfQcU9aMcmTIZvb0cxk00OaVBYLzuiIvcRqkMdAJiz/gSxOWU0AQjEPskHUCMQCrUKlZR4shPZuMvY6CCUOhxxKq/6LUoccWNHyL6sGkHRXE7j9HETh4uLKzRwNDVVA=" + } + ] + }, + "certificate": { + "rawBytes": "MIICKjCCAbCgAwIBAgIUaa62dj98DUB+TpyvKtVaR4vGSM0wCgYIKoZIzj0EAwMwODEVMBMGA1UEChMMR2l0SHViLCBJbmMuMR8wHQYDVQQDExZGdWxjaW8gSW50ZXJtZWRpYXRlIGwxMB4XDTI1MDMxMDE1MDMwMloXDTI2MDMxMDE1MDMwMlowKjEVMBMGA1UEChMMR2l0SHViLCBJbmMuMREwDwYDVQQDEwhBdHRlc3RlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABIMB7plPnZvBRlC2lvAocKTAqAPMJqstEqYk26e9vDJDC1yqoiHxZfPV4W/1RqUMZD1dFKm9t4RiSmm73/QnQKajgaUwgaIwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFOqaGpr5SbdYk5CQXsmmDZCBHR+XMB8GA1UdIwQYMBaAFMDhuFKkS08+3no4EQbPSY6hRZszMC0GA1UdEQQmMCSGImh0dHBzOi8vZG90Y29tLnJlbGVhc2VzLmdpdGh1Yi5jb20wCgYIKoZIzj0EAwMDaAAwZQIwWFdF6xcXazHVPHEAtd1SeaizLdY1erRl5hK+XlwhfpnasQHHZ9bdu4Zj8ARhW/AhAjEArujhmJGo7Fi4/Ek1RN8bufs6UhIQneQd/pxE8QdorwZkj2C8nf2EzrUYzlxKfktC" + } + }, + "dsseEnvelope": { + "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJ1cmkiOiJwa2c6Z2l0aHViL2JkZWhhbWVyL2RlbG1lQHY1IiwiZGlnZXN0Ijp7InNoYTEiOiJjNWUxN2E2MmUwNmExZDIwMTU3MDI0OWM2MWZhZTUzMWU5MjQ0ZTFiIn19LHsibmFtZSI6ImEuemlwIiwiZGlnZXN0Ijp7InNoYTI1NiI6ImY3MTY1ODQ4ZjlmNWRkYzU3OGQ3YWRiZDFmNTY2YTM5NDE2OTM4NWM3M2JkODhiZjYwZGY3ZTc1OWRiOGUwOGQifX0seyJuYW1lIjoiYi56aXAiLCJkaWdlc3QiOnsic2hhMjU2IjoiOGI3ZWIxNTcyMzQ2NjkyZmZkM2FlMDEyNDhjNzBhMzQxYWUzYWE4YmUxZGY4YjEyMzQ2YjUwYWNiOTAwMjI4MiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL2luLXRvdG8uaW8vYXR0ZXN0YXRpb24vcmVsZWFzZS92MC4xIiwicHJlZGljYXRlIjp7Im93bmVySWQiOiIzOTgwMjciLCJwdXJsIjoicGtnOmdpdGh1Yi9iZGVoYW1lci9kZWxtZUB2NSIsInJlbGVhc2VJZCI6IjIxODQxOTIxNyIsInJlcG9zaXRvcnkiOiJiZGVoYW1lci9kZWxtZSIsInJlcG9zaXRvcnlJZCI6IjkwNTk4ODA0NCIsInRhZyI6InY1In19", + "payloadType": "application/vnd.in-toto+json", + "signatures": [ + { + "sig": "MEQCIH6LDUanQYOCPovZlIqI1cE49SiGJdexR65qsAZHohsZAiA9w3usgPWtgn5voB8bRvpJQtjEVqC5eMDh3mJEdyMcXw==" + } + ] + } +} \ No newline at end of file diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 5fea30b7c..4e1be87e3 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -281,3 +281,11 @@ func StubFetchRelease(t *testing.T, reg *httpmock.Registry, owner, repoName, tag ) } } + +func StubFetchRefSHA(t *testing.T, reg *httpmock.Registry, owner, repoName, tagName, sha string) { + path := fmt.Sprintf("repos/%s/%s/git/refs/tags/%s", owner, repoName, tagName) + reg.Register( + httpmock.REST("GET", path), + httpmock.StringResponse(fmt.Sprintf(`{"object": {"sha": "%s"}}`, sha)), + ) +} diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index ddefdf5be..0c4443d04 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -27,14 +27,17 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) Use: "verify-asset ", Short: "Verify that a given asset originated from a specific GitHub Release.", Hidden: true, - Args: cobra.ExactArgs(2), + Args: cobra.MaximumNArgs(2), PreRunE: func(cmd *cobra.Command, args []string) error { - if len(args) < 2 { - return cmdutil.FlagErrorf("You must specify a tag and a file path") - } - tagName := args[0] - assetFilePath := args[1] + if len(args) == 2 { + opts.TagName = args[0] + opts.AssetFilePath = args[1] + } else if len(args) == 1 { + opts.AssetFilePath = args[0] + } else { + return cmdutil.FlagErrorf("you must specify an asset filepath") + } httpClient, err := f.HttpClient() if err != nil { @@ -53,8 +56,8 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) } *opts = attestation.AttestOptions{ - TagName: tagName, - AssetFilePath: assetFilePath, + TagName: opts.TagName, + AssetFilePath: opts.AssetFilePath, Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, @@ -114,6 +117,15 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) func verifyAssetRun(opts *attestation.AttestOptions) error { ctx := context.Background() + + if opts.TagName == "" { + release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) + if err != nil { + return err + } + opts.TagName = release.TagName + } + fileName := getFileName(opts.AssetFilePath) // calculate the digest of the file diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go new file mode 100644 index 000000000..eb333fc06 --- /dev/null +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -0,0 +1,158 @@ +package verifyasset + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmd/release/attestation" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cli/cli/v2/internal/ghrepo" + + "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/httpmock" +) + +func TestNewCmdVerifyAsset_Args(t *testing.T) { + tests := []struct { + name string + args []string + wantTag string + wantFile string + wantErr string + }{ + { + name: "valid args", + args: []string{"v1.2.3", "../../attestation/test/data/a.zip"}, + wantTag: "v1.2.3", + wantFile: "../../attestation/test/data/a.zip", + }, + { + name: "valid flag with no tag", + + args: []string{"../../attestation/test/data/a.zip"}, + wantFile: "../../attestation/test/data/a.zip", + }, + { + name: "no args", + args: []string{}, + wantErr: "you must specify an asset filepath", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testIO, _, _, _ := iostreams.Test() + var testReg httpmock.Registry + var metaResp = api.MetaResponse{ + Domains: api.Domain{ + ArtifactAttestations: api.ArtifactAttestations{}, + }, + } + testReg.Register(httpmock.REST(http.MethodGet, "meta"), + httpmock.StatusJSONResponse(200, &metaResp)) + + f := &cmdutil.Factory{ + IOStreams: testIO, + HttpClient: func() (*http.Client, error) { + reg := &testReg + client := &http.Client{} + httpmock.ReplaceTripper(client, reg) + return client, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + } + + var opts *attestation.AttestOptions + cmd := NewCmdVerifyAsset(f, func(o *attestation.AttestOptions) error { + opts = o + return nil + }) + cmd.SetArgs(tt.args) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + _, err := cmd.ExecuteC() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantTag, opts.TagName) + assert.Equal(t, tt.wantFile, opts.AssetFilePath) + } + }) + } +} + +func Test_verifyAssetRun_Success(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1.2.3" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + opts := &attestation.AttestOptions{ + TagName: tagName, + AssetFilePath: "../../attestation/test/data/a.zip", + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + err = verifyAssetRun(opts) + require.NoError(t, err) +} + +func Test_verifyAssetRun_NoAttestation(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &attestation.AttestOptions{ + TagName: "v1.2.3", + AssetFilePath: "artifact.tgz", + Repo: "owner/repo", + Limit: 10, + Logger: io.NewHandler(ios), + IO: ios, + APIClient: api.NewTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + EC: verification.EnforcementCriteria{}, + } + + err := verifyAssetRun(opts) + require.Error(t, err, "failed to get open local artifact: open artifact.tgz: no such file or director") +} + +func Test_getFileName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"foo/bar/baz.txt", "baz.txt"}, + {"baz.txt", "baz.txt"}, + {"/tmp/foo.tar.gz", "foo.tar.gz"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := getFileName(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 96c33c50b..76a5cd773 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -28,14 +28,12 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro Use: "verify []", Short: "Verify the attestation for a GitHub Release.", Hidden: true, - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { - if len(args) < 1 { - return cmdutil.FlagErrorf("You must specify a tag") + if len(args) > 0 { + opts.TagName = args[0] } - opts.TagName = args[0] - httpClient, err := f.HttpClient() if err != nil { return err @@ -115,6 +113,14 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro func verifyRun(opts *attestation.AttestOptions) error { ctx := context.Background() + if opts.TagName == "" { + release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) + if err != nil { + return err + } + opts.TagName = release.TagName + } + ref, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) if err != nil { return err diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go new file mode 100644 index 000000000..71a282aa2 --- /dev/null +++ b/pkg/cmd/release/verify/verify_test.go @@ -0,0 +1,142 @@ +package verify + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmd/release/attestation" + "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/require" + "gotest.tools/v3/assert" +) + +func TestNewCmdVerify_Args(t *testing.T) { + tests := []struct { + name string + args []string + wantTag string + wantErr string + }{ + { + name: "valid tag arg", + args: []string{"v1.2.3"}, + wantTag: "v1.2.3", + }, + { + name: "no tag arg", + args: []string{}, + wantTag: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testIO, _, _, _ := iostreams.Test() + var testReg httpmock.Registry + var metaResp = api.MetaResponse{ + Domains: api.Domain{ + ArtifactAttestations: api.ArtifactAttestations{}, + }, + } + testReg.Register(httpmock.REST(http.MethodGet, "meta"), + httpmock.StatusJSONResponse(200, &metaResp)) + + f := &cmdutil.Factory{ + IOStreams: testIO, + HttpClient: func() (*http.Client, error) { + reg := &testReg + client := &http.Client{} + httpmock.ReplaceTripper(client, reg) + return client, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + } + + var opts *attestation.AttestOptions + cmd := NewCmdVerify(f, func(o *attestation.AttestOptions) error { + opts = o + return nil + }) + cmd.SetArgs(tt.args) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + _, err := cmd.ExecuteC() + require.NoError(t, err) + assert.Equal(t, tt.wantTag, opts.TagName) + }) + } +} + +func Test_verifyRun_Success(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1.2.3" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + opts := &attestation.AttestOptions{ + TagName: tagName, + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + ec, err := attestation.NewEnforcementCriteria(opts) + require.NoError(t, err) + opts.EC = ec + + err = verifyRun(opts) + require.NoError(t, err) +} + +func Test_verifyRun_NoAttestation(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1.2.3" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + opts := &attestation.AttestOptions{ + TagName: tagName, + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewFailTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + ec, err := attestation.NewEnforcementCriteria(opts) + require.NoError(t, err) + opts.EC = ec + + err = verifyRun(opts) + require.Error(t, err, "failed to fetch attestations from owner/repo") +} From 3b17318ee48dc59497f1703c5787262284e9d9f5 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 08:31:07 -0700 Subject: [PATCH 202/249] fix test --- .../test/data/{a.zip => github_release_artifact.zip} | 0 pkg/cmd/release/verify-asset/verify-asset.go | 10 ++++++---- pkg/cmd/release/verify-asset/verify-asset_test.go | 10 +++++----- pkg/cmd/release/verify/verify.go | 9 +++++---- 4 files changed, 16 insertions(+), 13 deletions(-) rename pkg/cmd/attestation/test/data/{a.zip => github_release_artifact.zip} (100%) diff --git a/pkg/cmd/attestation/test/data/a.zip b/pkg/cmd/attestation/test/data/github_release_artifact.zip similarity index 100% rename from pkg/cmd/attestation/test/data/a.zip rename to pkg/cmd/attestation/test/data/github_release_artifact.zip diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 0c4443d04..c87fc8e65 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -89,11 +89,17 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return err } + // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes + if runF != nil { + return runF(opts) + } + config := verification.SigstoreConfig{ Logger: opts.Logger, NoPublicGood: true, TrustDomain: td, } + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) @@ -103,10 +109,6 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) opts.SigstoreVerifier = sigstoreVerifier opts.EC = ec - if runF != nil { - return runF(opts) - } - return verifyAssetRun(opts) }, } diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go index eb333fc06..784c43e1e 100644 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -30,15 +30,15 @@ func TestNewCmdVerifyAsset_Args(t *testing.T) { }{ { name: "valid args", - args: []string{"v1.2.3", "../../attestation/test/data/a.zip"}, + args: []string{"v1.2.3", "../../attestation/test/data/github_release_artifact.zip"}, wantTag: "v1.2.3", - wantFile: "../../attestation/test/data/a.zip", + wantFile: "../../attestation/test/data/github_release_artifact.zip", }, { name: "valid flag with no tag", - args: []string{"../../attestation/test/data/a.zip"}, - wantFile: "../../attestation/test/data/a.zip", + args: []string{"../../attestation/test/data/github_release_artifact.zip"}, + wantFile: "../../attestation/test/data/github_release_artifact.zip", }, { name: "no args", @@ -107,7 +107,7 @@ func Test_verifyAssetRun_Success(t *testing.T) { opts := &attestation.AttestOptions{ TagName: tagName, - AssetFilePath: "../../attestation/test/data/a.zip", + AssetFilePath: "../../attestation/test/data/github_release_artifact.zip", Repo: "owner/repo", Owner: "owner", Limit: 10, diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 76a5cd773..d58628725 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -83,6 +83,11 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro return err } + // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes + if runF != nil { + return runF(opts) + } + config := verification.SigstoreConfig{ Logger: opts.Logger, NoPublicGood: true, @@ -98,10 +103,6 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro opts.SigstoreVerifier = sigstoreVerifier opts.EC = ec - if runF != nil { - return runF(opts) - } - return verifyRun(opts) }, } From 8e6ed6eb38b2c51213e45605b82e7fc0738beb62 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 09:30:05 -0700 Subject: [PATCH 203/249] improve test --- .../test/data/github_release_artifact.zip | 2 +- .../data/github_release_artifact_invalid.zip | 14 ++++ pkg/cmd/release/attestation/options.go | 11 --- pkg/cmd/release/attestation/options_test.go | 5 +- pkg/cmd/release/verify-asset/verify-asset.go | 4 +- .../release/verify-asset/verify-asset_test.go | 77 ++++++++++++++++++- pkg/cmd/release/verify/verify.go | 7 ++ pkg/cmd/release/verify/verify_test.go | 40 +++++++++- 8 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 pkg/cmd/attestation/test/data/github_release_artifact_invalid.zip diff --git a/pkg/cmd/attestation/test/data/github_release_artifact.zip b/pkg/cmd/attestation/test/data/github_release_artifact.zip index f4595ef44..934302cd9 100644 --- a/pkg/cmd/attestation/test/data/github_release_artifact.zip +++ b/pkg/cmd/attestation/test/data/github_release_artifact.zip @@ -1,4 +1,4 @@ -a # frozen_string_literal: true +# frozen_string_literal: true source "https://rubygems.org" diff --git a/pkg/cmd/attestation/test/data/github_release_artifact_invalid.zip b/pkg/cmd/attestation/test/data/github_release_artifact_invalid.zip new file mode 100644 index 000000000..26b414dbc --- /dev/null +++ b/pkg/cmd/attestation/test/data/github_release_artifact_invalid.zip @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +source "https://rubygems.pkg.github.com/github" do + gem "entitlements-aad-plugin", "~> 1.0" + gem "entitlements-app", "~> 1.2" + gem "entitlements-github-plugin", "~> 1.2" + gem "entitlements-gitrepo-auditor-plugin", "~> 1.0" + gem "entitlements-jit-github-plugin", "~> 1.0" + gem "entitlements-lib", "~> 0.2" + gem "entitlements-stafftools-plugin", "~> 1.0" +end + diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/attestation/options.go index 9dd84647e..7140c4f33 100644 --- a/pkg/cmd/release/attestation/options.go +++ b/pkg/cmd/release/attestation/options.go @@ -10,7 +10,6 @@ import ( "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmdutil" @@ -27,22 +26,12 @@ type AttestOptions struct { Exporter cmdutil.Exporter TagName string TrustedRoot string - DigestAlgorithm string Limit int - OIDCIssuer string Owner string PredicateType string Repo string - SAN string - SANRegex string - SignerDigest string - SignerRepo string - SignerWorkflow string - SourceDigest string - SourceRef string APIClient api.Client Logger *io.Handler - OCIClient oci.Client SigstoreVerifier verification.SigstoreVerifier Hostname string EC verification.EnforcementCriteria diff --git a/pkg/cmd/release/attestation/options_test.go b/pkg/cmd/release/attestation/options_test.go index 89d260199..125723b17 100644 --- a/pkg/cmd/release/attestation/options_test.go +++ b/pkg/cmd/release/attestation/options_test.go @@ -7,9 +7,8 @@ import ( func TestAttestOptions_AreFlagsValid_Valid(t *testing.T) { opts := &AttestOptions{ - Repo: "owner/repo", - SignerRepo: "signer/repo", - Limit: 10, + Repo: "owner/repo", + Limit: 10, } if err := opts.AreFlagsValid(); err != nil { t.Errorf("expected no error, got %v", err) diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index c87fc8e65..3845ada9b 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -3,6 +3,7 @@ package verifyasset import ( "context" "errors" + "fmt" "path/filepath" "github.com/cli/cli/v2/pkg/cmd/attestation/auth" @@ -95,6 +96,7 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) } config := verification.SigstoreConfig{ + HttpClient: opts.HttpClient, Logger: opts.Logger, NoPublicGood: true, TrustDomain: td, @@ -172,7 +174,7 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { if len(filteredAttestations) == 0 { opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) - return nil + return fmt.Errorf("no attestations found for %s in release %s", fileName, opts.TagName) } opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go index 784c43e1e..2a26dc6d3 100644 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -95,7 +95,7 @@ func TestNewCmdVerifyAsset_Args(t *testing.T) { func Test_verifyAssetRun_Success(t *testing.T) { ios, _, _, _ := iostreams.Test() - tagName := "v1.2.3" + tagName := "v5" fakeHTTP := &httpmock.Registry{} defer fakeHTTP.Verify(t) @@ -114,12 +114,81 @@ func Test_verifyAssetRun_Success(t *testing.T) { Logger: io.NewHandler(ios), APIClient: api.NewTestClient(), SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + PredicateType: attestation.ReleasePredicateType, + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + ec, err := attestation.NewEnforcementCriteria(opts) + require.NoError(t, err) + opts.EC = ec + + err = verifyAssetRun(opts) + require.NoError(t, err) +} + +func Test_verifyAssetRun_Failed_With_Wrong_tag(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + opts := &attestation.AttestOptions{ + TagName: tagName, + AssetFilePath: "../../attestation/test/data/github_release_artifact.zip", + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + PredicateType: attestation.ReleasePredicateType, + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + ec, err := attestation.NewEnforcementCriteria(opts) + require.NoError(t, err) + opts.EC = ec + + err = verifyAssetRun(opts) + require.Error(t, err, "no attestations found for github_release_artifact.zip in release v1") +} + +func Test_verifyAssetRun_Failed_With_Invalid_Artifact(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1.2.3" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + opts := &attestation.AttestOptions{ + TagName: tagName, + AssetFilePath: "../../attestation/test/data/github_release_artifact_invalid.zip", + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + PredicateType: attestation.ReleasePredicateType, HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, } err = verifyAssetRun(opts) - require.NoError(t, err) + require.Error(t, err, "no attestations found for github_release_artifact_invalid.zip in release v1.2.3") } func Test_verifyAssetRun_NoAttestation(t *testing.T) { @@ -133,7 +202,9 @@ func Test_verifyAssetRun_NoAttestation(t *testing.T) { IO: ios, APIClient: api.NewTestClient(), SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - EC: verification.EnforcementCriteria{}, + PredicateType: attestation.ReleasePredicateType, + + EC: verification.EnforcementCriteria{}, } err := verifyAssetRun(opts) diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index d58628725..c6579f825 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -3,6 +3,7 @@ package verify import ( "context" "errors" + "fmt" v1 "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/encoding/protojson" @@ -89,6 +90,7 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro } config := verification.SigstoreConfig{ + HttpClient: opts.HttpClient, Logger: opts.Logger, NoPublicGood: true, TrustDomain: td, @@ -148,6 +150,11 @@ func verifyRun(opts *attestation.AttestOptions) error { return err } + if len(filteredAttestations) == 0 { + opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for release %s in %s\n"), opts.TagName, opts.Repo) + return fmt.Errorf("no attestations found for release %s in %s", opts.TagName, opts.Repo) + } + opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index 71a282aa2..53078f450 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -79,7 +79,7 @@ func TestNewCmdVerify_Args(t *testing.T) { func Test_verifyRun_Success(t *testing.T) { ios, _, _, _ := iostreams.Test() - tagName := "v1.2.3" + tagName := "v5" fakeHTTP := &httpmock.Registry{} defer fakeHTTP.Verify(t) @@ -99,6 +99,7 @@ func Test_verifyRun_Success(t *testing.T) { SigstoreVerifier: verification.NewMockSigstoreVerifier(t), HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, + PredicateType: attestation.ReleasePredicateType, } ec, err := attestation.NewEnforcementCriteria(opts) @@ -109,7 +110,41 @@ func Test_verifyRun_Success(t *testing.T) { require.NoError(t, err) } -func Test_verifyRun_NoAttestation(t *testing.T) { +func Test_verifyRun_Failed_With_Invalid_Tag(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1.2.3" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + opts := &attestation.AttestOptions{ + TagName: tagName, + Repo: "owner/repo", + Owner: "owner", + Limit: 10, + Logger: io.NewHandler(ios), + APIClient: api.NewFailTestClient(), + SigstoreVerifier: verification.NewMockSigstoreVerifier(t), + PredicateType: attestation.ReleasePredicateType, + + HttpClient: &http.Client{Transport: fakeHTTP}, + BaseRepo: baseRepo, + } + + ec, err := attestation.NewEnforcementCriteria(opts) + require.NoError(t, err) + opts.EC = ec + + err = verifyRun(opts) + require.Error(t, err, "failed to fetch attestations from owner/repo") +} + +func Test_verifyRun_Failed_NoAttestation(t *testing.T) { ios, _, _, _ := iostreams.Test() tagName := "v1.2.3" @@ -131,6 +166,7 @@ func Test_verifyRun_NoAttestation(t *testing.T) { SigstoreVerifier: verification.NewMockSigstoreVerifier(t), HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, + PredicateType: attestation.ReleasePredicateType, } ec, err := attestation.NewEnforcementCriteria(opts) From e00e1c414b1b1f5e1fb1be571158c1e228b7b986 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 09:46:46 -0700 Subject: [PATCH 204/249] clean the path --- pkg/cmd/release/verify-asset/verify-asset.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 3845ada9b..f7e651c6b 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -111,6 +111,8 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) opts.SigstoreVerifier = sigstoreVerifier opts.EC = ec + opts.Clean() + return verifyAssetRun(opts) }, } From 6d90ad6a2db2e0bac4e4dcb6b1af3f5b5665c67d Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 09:58:23 -0700 Subject: [PATCH 205/249] clean the path --- pkg/cmd/release/verify-asset/verify-asset_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go index 2a26dc6d3..81cf73551 100644 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -122,7 +122,7 @@ func Test_verifyAssetRun_Success(t *testing.T) { ec, err := attestation.NewEnforcementCriteria(opts) require.NoError(t, err) opts.EC = ec - + opts.Clean() err = verifyAssetRun(opts) require.NoError(t, err) } From 2312cfb1460f68366cfd9cb92c1a97e720153cc7 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 10:07:18 -0700 Subject: [PATCH 206/249] clean the path --- pkg/cmd/release/verify-asset/verify-asset_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go index 81cf73551..1760d4a05 100644 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/test" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmd/release/attestation" "github.com/cli/cli/v2/pkg/cmdutil" @@ -107,7 +108,7 @@ func Test_verifyAssetRun_Success(t *testing.T) { opts := &attestation.AttestOptions{ TagName: tagName, - AssetFilePath: "../../attestation/test/data/github_release_artifact.zip", + AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), Repo: "owner/repo", Owner: "owner", Limit: 10, @@ -141,7 +142,7 @@ func Test_verifyAssetRun_Failed_With_Wrong_tag(t *testing.T) { opts := &attestation.AttestOptions{ TagName: tagName, - AssetFilePath: "../../attestation/test/data/github_release_artifact.zip", + AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), Repo: "owner/repo", Owner: "owner", Limit: 10, @@ -175,7 +176,7 @@ func Test_verifyAssetRun_Failed_With_Invalid_Artifact(t *testing.T) { opts := &attestation.AttestOptions{ TagName: tagName, - AssetFilePath: "../../attestation/test/data/github_release_artifact_invalid.zip", + AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), Repo: "owner/repo", Owner: "owner", Limit: 10, From 53cb90aecaf2397359bab99167c64975a96982f5 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 11:16:04 -0700 Subject: [PATCH 207/249] debug windows env --- .github/workflows/go.yml | 5 ++--- pkg/cmd/release/verify-asset/verify-asset_test.go | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 9b22701a7..903c35db0 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [windows-latest] runs-on: ${{ matrix.os }} steps: @@ -25,8 +25,7 @@ jobs: run: go mod download - name: Run unit and integration tests - run: go test -race -tags=integration ./... - + run: go test -v -race -tags=integration ./cmd/release - name: Build run: go build -v ./cmd/gh diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go index 1760d4a05..c732bfd86 100644 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -164,7 +164,7 @@ func Test_verifyAssetRun_Failed_With_Wrong_tag(t *testing.T) { func Test_verifyAssetRun_Failed_With_Invalid_Artifact(t *testing.T) { ios, _, _, _ := iostreams.Test() - tagName := "v1.2.3" + tagName := "v5" fakeHTTP := &httpmock.Registry{} defer fakeHTTP.Verify(t) From b423edff7b478377790f913d7ab244fe8617116f Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 11:51:33 -0700 Subject: [PATCH 208/249] debug windows env --- .github/workflows/go.yml | 28 +------------------- pkg/cmd/release/verify-asset/verify-asset.go | 2 +- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 903c35db0..ef171441b 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -25,32 +25,6 @@ jobs: run: go mod download - name: Run unit and integration tests - run: go test -v -race -tags=integration ./cmd/release + run: go test -v ./pkg/cmd/release/verify-asset/... - name: Build run: go build -v ./cmd/gh - - integration-tests: - env: - GH_TOKEN: ${{ github.token }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - runs-on: ${{ matrix.os }} - - steps: - - name: Check out code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: "go.mod" - - - name: Build executable - run: make - - - name: Run attestation command set integration tests - shell: bash - run: | - ./test/integration/attestation-cmd/run-all-tests.sh "${{ matrix.os }}" diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index f7e651c6b..e2b45f7f1 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -176,7 +176,7 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { if len(filteredAttestations) == 0 { opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) - return fmt.Errorf("no attestations found for %s in release %s", fileName, opts.TagName) + return fmt.Errorf("release %s does not contain %s (%s)", opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) } opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) From 4b1108734c1f4792e6e63eda71dcdc59ac393e16 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 12:18:23 -0700 Subject: [PATCH 209/249] debug windows env --- .github/workflows/go.yml | 2 +- pkg/cmd/attestation/artifact/file_test.go | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 pkg/cmd/attestation/artifact/file_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index ef171441b..8a4ead221 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -25,6 +25,6 @@ jobs: run: go mod download - name: Run unit and integration tests - run: go test -v ./pkg/cmd/release/verify-asset/... + run: go test -v ./pkg/cmd/release/verify-asset ./pkg/cmd/attestation/artifact - name: Build run: go build -v ./cmd/gh diff --git a/pkg/cmd/attestation/artifact/file_test.go b/pkg/cmd/attestation/artifact/file_test.go new file mode 100644 index 000000000..ed9e7049a --- /dev/null +++ b/pkg/cmd/attestation/artifact/file_test.go @@ -0,0 +1,23 @@ +package artifact + +import ( + "testing" + + "github.com/cli/cli/v2/pkg/cmd/attestation/test" + "github.com/stretchr/testify/require" +) + +func Test_digestLocalFileArtifact_withRealZip(t *testing.T) { + // Path to the test artifact + artifactPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") + + // Calculate expected digest using the same algorithm as the function under test + expectedDigest := "f7165848f9f5ddc578d7adbd1f566a394169385c73bd88bf60df7e759db8e08d" + + // Call the function under test + artifact, err := digestLocalFileArtifact(artifactPath, "sha256") + require.NoError(t, err) + require.Equal(t, "file://"+artifactPath, artifact.URL) + require.Equal(t, expectedDigest, artifact.digest) + require.Equal(t, "sha256", artifact.digestAlg) +} From 96db923f19a72d954647f4b3564a220e58e06fe5 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 12:33:40 -0700 Subject: [PATCH 210/249] revert the workflow --- .github/workflows/go.yml | 31 +++++++++++++++++++++++++-- pkg/cmd/release/verify/verify_test.go | 2 +- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 8a4ead221..4be1a55d2 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: @@ -25,6 +25,33 @@ jobs: run: go mod download - name: Run unit and integration tests - run: go test -v ./pkg/cmd/release/verify-asset ./pkg/cmd/attestation/artifact + run: go test -race -tags=integration ./... + - name: Build run: go build -v ./cmd/gh + + integration-tests: + env: + GH_TOKEN: ${{ github.token }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + + - name: Build executable + run: make + + - name: Run attestation command set integration tests + shell: bash + run: | + ./test/integration/attestation-cmd/run-all-tests.sh "${{ matrix.os }}" diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index 53078f450..22eaba54a 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -14,8 +14,8 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gotest.tools/v3/assert" ) func TestNewCmdVerify_Args(t *testing.T) { From df87b1559806c93910e579b1b0ac07cca8202f8f Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 12:53:19 -0700 Subject: [PATCH 211/249] clean the code --- .github/workflows/go.yml | 2 +- go.mod | 1 - go.sum | 6 --- pkg/cmd/release/shared/fetch.go | 4 +- pkg/cmd/release/verify-asset/verify-asset.go | 41 +++++++++++--------- pkg/cmd/release/verify/verify.go | 36 +++++++++-------- 6 files changed, 45 insertions(+), 45 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 4be1a55d2..9b22701a7 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -29,7 +29,7 @@ jobs: - name: Build run: go build -v ./cmd/gh - + integration-tests: env: GH_TOKEN: ${{ github.token }} diff --git a/go.mod b/go.mod index bb50fba67..a4c973df1 100644 --- a/go.mod +++ b/go.mod @@ -60,7 +60,6 @@ require ( google.golang.org/protobuf v1.36.6 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 - gotest.tools/v3 v3.0.3 ) require ( diff --git a/go.sum b/go.sum index 1835a69b2..718e0ca67 100644 --- a/go.sum +++ b/go.sum @@ -245,7 +245,6 @@ 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.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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= @@ -411,7 +410,6 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH 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.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -485,7 +483,6 @@ 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.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 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.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= @@ -565,7 +562,6 @@ golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc/go.mod h1:XtvwrStGgqGPLc4cjQ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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= @@ -603,13 +599,11 @@ golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 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-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 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= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.230.0 h1:2u1hni3E+UXAXrONrrkfWpi/V6cyKVAbfGVeGtC3OxM= google.golang.org/api v0.230.0/go.mod h1:aqvtoMk7YkiXx+6U12arQFExiRV9D/ekvMCwCd/TksQ= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 6d6d16473..322f33c17 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -146,7 +146,7 @@ func FetchRefSHA(ctx context.Context, httpClient *http.Client, repo ghrepo.Inter } defer resp.Body.Close() - if resp.StatusCode == 404 { + if resp.StatusCode == http.StatusNotFound { _, _ = io.Copy(io.Discard, resp.Body) // ErrRefNotFound return "", ErrReleaseNotFound @@ -248,7 +248,7 @@ func fetchReleasePath(ctx context.Context, httpClient *http.Client, host string, } defer resp.Body.Close() - if resp.StatusCode == 404 { + if resp.StatusCode == http.StatusNotFound { _, _ = io.Copy(io.Discard, resp.Body) return nil, ErrReleaseNotFound } else if resp.StatusCode > 299 { diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index e2b45f7f1..8890d8a0d 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -84,35 +84,23 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return err } + opts.TrustedRoot = td + ec, err := attestation.NewEnforcementCriteria(opts) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err } + opts.EC = ec + + opts.Clean() + // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes if runF != nil { return runF(opts) } - config := verification.SigstoreConfig{ - HttpClient: opts.HttpClient, - Logger: opts.Logger, - NoPublicGood: true, - TrustDomain: td, - } - - sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) - return err - } - - opts.SigstoreVerifier = sigstoreVerifier - opts.EC = ec - - opts.Clean() - return verifyAssetRun(opts) }, } @@ -124,6 +112,23 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) func verifyAssetRun(opts *attestation.AttestOptions) error { ctx := context.Background() + if opts.SigstoreVerifier == nil { + config := verification.SigstoreConfig{ + HttpClient: opts.HttpClient, + Logger: opts.Logger, + NoPublicGood: true, + TrustDomain: opts.TrustedRoot, + } + + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + return err + } + + opts.SigstoreVerifier = sigstoreVerifier + } + if opts.TagName == "" { release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) if err != nil { diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index c6579f825..2b0fd8907 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -77,34 +77,19 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) return err } + opts.TrustedRoot = td ec, err := attestation.NewEnforcementCriteria(opts) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err } + opts.EC = ec // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes if runF != nil { return runF(opts) } - - config := verification.SigstoreConfig{ - HttpClient: opts.HttpClient, - Logger: opts.Logger, - NoPublicGood: true, - TrustDomain: td, - } - - sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) - return err - } - - opts.SigstoreVerifier = sigstoreVerifier - opts.EC = ec - return verifyRun(opts) }, } @@ -116,6 +101,23 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro func verifyRun(opts *attestation.AttestOptions) error { ctx := context.Background() + if opts.SigstoreVerifier == nil { + config := verification.SigstoreConfig{ + HttpClient: opts.HttpClient, + Logger: opts.Logger, + NoPublicGood: true, + TrustDomain: opts.TrustedRoot, + } + + sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) + if err != nil { + opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + return err + } + + opts.SigstoreVerifier = sigstoreVerifier + } + if opts.TagName == "" { release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) if err != nil { From 699ccc1a9488c07154350c0d9698eb9088a467e3 Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Fri, 30 May 2025 13:19:41 -0700 Subject: [PATCH 212/249] empty commit From 56f887709751f4bb49e1d2b630fb2bb93cf8931c Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 13:31:23 -0700 Subject: [PATCH 213/249] update the artifact and bundle for testing --- pkg/cmd/attestation/artifact/file_test.go | 2 +- .../test/data/github_release_artifact.zip | Bin 797 -> 169 bytes .../data/github_release_artifact_invalid.zip | Bin 427 -> 188 bytes .../test/data/github_release_bundle.json | 6 +++--- .../release/verify-asset/verify-asset_test.go | 10 +++++----- pkg/cmd/release/verify/verify_test.go | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/attestation/artifact/file_test.go b/pkg/cmd/attestation/artifact/file_test.go index ed9e7049a..54768e93e 100644 --- a/pkg/cmd/attestation/artifact/file_test.go +++ b/pkg/cmd/attestation/artifact/file_test.go @@ -12,7 +12,7 @@ func Test_digestLocalFileArtifact_withRealZip(t *testing.T) { artifactPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") // Calculate expected digest using the same algorithm as the function under test - expectedDigest := "f7165848f9f5ddc578d7adbd1f566a394169385c73bd88bf60df7e759db8e08d" + expectedDigest := "e15b593c6ab8d7725a3cc82226ef816cac6bf9c70eed383bd459295cc65f5ec3" // Call the function under test artifact, err := digestLocalFileArtifact(artifactPath, "sha256") diff --git a/pkg/cmd/attestation/test/data/github_release_artifact.zip b/pkg/cmd/attestation/test/data/github_release_artifact.zip index 934302cd93449f12d999ab9fb7f4e28fb778bcf2..a4d222eb9e344b4038ce0a078f032f652aa823fe 100644 GIT binary patch literal 169 zcmWIWW@h1H0DunGxpHmUab_+LQgP!yV;!$Ppl^V zg!T{lt`BUgTsfF)}>=MvczU_d*!Uc zg0YLAacGlE9-7ND3au*ohz4+9$hmn z-dz&YF*645Ojmm;XPU&M>pCI&I8g%HswgaB4s2h+2|R}Da-%(sjvdivcVFEi`%J3)UDa~SuIEX pFaO-tE&@=E)abT^KmPmd&Bz^H6Fii78V(=gdnjNu(ogtx@^7uo@>&1@ diff --git a/pkg/cmd/attestation/test/data/github_release_artifact_invalid.zip b/pkg/cmd/attestation/test/data/github_release_artifact_invalid.zip index 26b414dbce27d457432613f45174596f7d158eaf..fcdda88fe075d6013f3426d3f40603862d314360 100644 GIT binary patch literal 188 zcmYdH3GilS;bH)R>dg21_eND!{9efHx}}NF^f> M`T=QI5Ql*a02C=HfdBvi literal 427 zcmaKmF%H5o3`O^x!U`)*3u6baQl->Q18E#N&Hw{v2dRjmYME@w@B6<4j7(3AT^9Dy zb-@d=4J`;4R4P;9D}tUxQf}*-OaE{eLWz{f{0{G-XV-zm| z=g-WQwL_IcaX#uJ=+^~KrqSDEN Date: Fri, 30 May 2025 13:43:07 -0700 Subject: [PATCH 214/249] moved to shared lib --- .../{attestation => shared}/attestation.go | 0 .../{attestation => shared}/options.go | 2 +- .../{attestation => shared}/options_test.go | 2 +- .../release/{attestation => shared}/policy.go | 2 +- .../{attestation => shared}/policy_test.go | 2 +- pkg/cmd/release/verify-asset/verify-asset.go | 21 ++++++++--------- .../release/verify-asset/verify-asset_test.go | 14 +++++------ pkg/cmd/release/verify/verify.go | 19 ++++++++------- pkg/cmd/release/verify/verify_test.go | 23 +++++++++---------- 9 files changed, 41 insertions(+), 44 deletions(-) rename pkg/cmd/release/{attestation => shared}/attestation.go (100%) rename pkg/cmd/release/{attestation => shared}/options.go (99%) rename pkg/cmd/release/{attestation => shared}/options_test.go (98%) rename pkg/cmd/release/{attestation => shared}/policy.go (99%) rename pkg/cmd/release/{attestation => shared}/policy_test.go (98%) diff --git a/pkg/cmd/release/attestation/attestation.go b/pkg/cmd/release/shared/attestation.go similarity index 100% rename from pkg/cmd/release/attestation/attestation.go rename to pkg/cmd/release/shared/attestation.go diff --git a/pkg/cmd/release/attestation/options.go b/pkg/cmd/release/shared/options.go similarity index 99% rename from pkg/cmd/release/attestation/options.go rename to pkg/cmd/release/shared/options.go index 7140c4f33..86e8ac78b 100644 --- a/pkg/cmd/release/attestation/options.go +++ b/pkg/cmd/release/shared/options.go @@ -1,4 +1,4 @@ -package attestation +package shared import ( "fmt" diff --git a/pkg/cmd/release/attestation/options_test.go b/pkg/cmd/release/shared/options_test.go similarity index 98% rename from pkg/cmd/release/attestation/options_test.go rename to pkg/cmd/release/shared/options_test.go index 125723b17..7a8fa73dc 100644 --- a/pkg/cmd/release/attestation/options_test.go +++ b/pkg/cmd/release/shared/options_test.go @@ -1,4 +1,4 @@ -package attestation +package shared import ( "errors" diff --git a/pkg/cmd/release/attestation/policy.go b/pkg/cmd/release/shared/policy.go similarity index 99% rename from pkg/cmd/release/attestation/policy.go rename to pkg/cmd/release/shared/policy.go index d7bf0f096..0e3bb322b 100644 --- a/pkg/cmd/release/attestation/policy.go +++ b/pkg/cmd/release/shared/policy.go @@ -1,4 +1,4 @@ -package attestation +package shared import ( "fmt" diff --git a/pkg/cmd/release/attestation/policy_test.go b/pkg/cmd/release/shared/policy_test.go similarity index 98% rename from pkg/cmd/release/attestation/policy_test.go rename to pkg/cmd/release/shared/policy_test.go index 57eab86b2..72cc53c2a 100644 --- a/pkg/cmd/release/attestation/policy_test.go +++ b/pkg/cmd/release/shared/policy_test.go @@ -1,4 +1,4 @@ -package attestation +package shared import ( "testing" diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 8890d8a0d..4100d179e 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -14,15 +14,14 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmd/release/attestation" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) -func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) error) *cobra.Command { - opts := &attestation.AttestOptions{} +func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *cobra.Command { + opts := &shared.AttestOptions{} cmd := &cobra.Command{ Use: "verify-asset ", @@ -56,14 +55,14 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return err } - *opts = attestation.AttestOptions{ + *opts = shared.AttestOptions{ TagName: opts.TagName, AssetFilePath: opts.AssetFilePath, Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, Owner: baseRepo.RepoOwner(), - PredicateType: attestation.ReleasePredicateType, + PredicateType: shared.ReleasePredicateType, Logger: logger, HttpClient: httpClient, BaseRepo: baseRepo, @@ -86,7 +85,7 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) opts.TrustedRoot = td - ec, err := attestation.NewEnforcementCriteria(opts) + ec, err := shared.NewEnforcementCriteria(opts) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err @@ -109,7 +108,7 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*attestation.AttestOptions) return cmd } -func verifyAssetRun(opts *attestation.AttestOptions) error { +func verifyAssetRun(opts *shared.AttestOptions) error { ctx := context.Background() if opts.SigstoreVerifier == nil { @@ -156,7 +155,7 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) // Attestation fetching - attestations, logMsg, err := attestation.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) + attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) @@ -167,13 +166,13 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { } // Filter attestations by tag - filteredAttestations, err := attestation.FilterAttestationsByTag(attestations, opts.TagName) + filteredAttestations, err := shared.FilterAttestationsByTag(attestations, opts.TagName) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) return err } - filteredAttestations, err = attestation.FilterAttestationsByFileDigest(filteredAttestations, opts.Repo, opts.TagName, fileDigest.Digest()) + filteredAttestations, err = shared.FilterAttestationsByFileDigest(filteredAttestations, opts.Repo, opts.TagName, fileDigest.Digest()) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) return err @@ -187,7 +186,7 @@ func verifyAssetRun(opts *attestation.AttestOptions) error { opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations - verified, errMsg, err := attestation.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) + verified, errMsg, err := shared.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go index 0976807b1..a85c9066e 100644 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ b/pkg/cmd/release/verify-asset/verify-asset_test.go @@ -9,7 +9,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/test" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmd/release/attestation" + "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -17,7 +17,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/cmd/release/shared" + attestation "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/httpmock" ) @@ -72,8 +72,8 @@ func TestNewCmdVerifyAsset_Args(t *testing.T) { }, } - var opts *attestation.AttestOptions - cmd := NewCmdVerifyAsset(f, func(o *attestation.AttestOptions) error { + var opts *shared.AttestOptions + cmd := NewCmdVerifyAsset(f, func(o *shared.AttestOptions) error { opts = o return nil }) @@ -106,7 +106,7 @@ func Test_verifyAssetRun_Success(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - opts := &attestation.AttestOptions{ + opts := &shared.AttestOptions{ TagName: tagName, AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), Repo: "owner/repo", @@ -115,12 +115,12 @@ func Test_verifyAssetRun_Success(t *testing.T) { Logger: io.NewHandler(ios), APIClient: api.NewTestClient(), SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: attestation.ReleasePredicateType, + PredicateType: shared.ReleasePredicateType, HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, } - ec, err := attestation.NewEnforcementCriteria(opts) + ec, err := shared.NewEnforcementCriteria(opts) require.NoError(t, err) opts.EC = ec opts.Clean() diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 2b0fd8907..ff8f7147e 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -14,7 +14,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/auth" att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmd/release/attestation" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -22,8 +21,8 @@ import ( "github.com/spf13/cobra" ) -func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) error) *cobra.Command { - opts := &attestation.AttestOptions{} +func NewCmdVerify(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *cobra.Command { + opts := &shared.AttestOptions{} cmd := &cobra.Command{ Use: "verify []", @@ -52,13 +51,13 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro return err } - *opts = attestation.AttestOptions{ + *opts = shared.AttestOptions{ TagName: opts.TagName, Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), APIClient: api.NewLiveClient(httpClient, hostname, logger), Limit: 10, Owner: baseRepo.RepoOwner(), - PredicateType: attestation.ReleasePredicateType, + PredicateType: shared.ReleasePredicateType, Logger: logger, HttpClient: httpClient, BaseRepo: baseRepo, @@ -79,7 +78,7 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro } opts.TrustedRoot = td - ec, err := attestation.NewEnforcementCriteria(opts) + ec, err := shared.NewEnforcementCriteria(opts) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) return err @@ -98,7 +97,7 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*attestation.AttestOptions) erro return cmd } -func verifyRun(opts *attestation.AttestOptions) error { +func verifyRun(opts *shared.AttestOptions) error { ctx := context.Background() if opts.SigstoreVerifier == nil { @@ -135,7 +134,7 @@ func verifyRun(opts *attestation.AttestOptions) error { opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) // Attestation fetching - attestations, logMsg, err := attestation.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) + attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) @@ -146,7 +145,7 @@ func verifyRun(opts *attestation.AttestOptions) error { } // Filter attestations by predicate tag - filteredAttestations, err := attestation.FilterAttestationsByTag(attestations, opts.TagName) + filteredAttestations, err := shared.FilterAttestationsByTag(attestations, opts.TagName) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) return err @@ -160,7 +159,7 @@ func verifyRun(opts *attestation.AttestOptions) error { opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) // Verify attestations - verified, errMsg, err := attestation.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) + verified, errMsg, err := shared.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index 9668a71ff..b0a1c7df5 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -9,7 +9,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmd/release/attestation" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -61,8 +60,8 @@ func TestNewCmdVerify_Args(t *testing.T) { }, } - var opts *attestation.AttestOptions - cmd := NewCmdVerify(f, func(o *attestation.AttestOptions) error { + var opts *shared.AttestOptions + cmd := NewCmdVerify(f, func(o *shared.AttestOptions) error { opts = o return nil }) @@ -89,7 +88,7 @@ func Test_verifyRun_Success(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - opts := &attestation.AttestOptions{ + opts := &shared.AttestOptions{ TagName: tagName, Repo: "owner/repo", Owner: "owner", @@ -99,10 +98,10 @@ func Test_verifyRun_Success(t *testing.T) { SigstoreVerifier: verification.NewMockSigstoreVerifier(t), HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, - PredicateType: attestation.ReleasePredicateType, + PredicateType: shared.ReleasePredicateType, } - ec, err := attestation.NewEnforcementCriteria(opts) + ec, err := shared.NewEnforcementCriteria(opts) require.NoError(t, err) opts.EC = ec @@ -122,7 +121,7 @@ func Test_verifyRun_Failed_With_Invalid_Tag(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - opts := &attestation.AttestOptions{ + opts := &shared.AttestOptions{ TagName: tagName, Repo: "owner/repo", Owner: "owner", @@ -130,13 +129,13 @@ func Test_verifyRun_Failed_With_Invalid_Tag(t *testing.T) { Logger: io.NewHandler(ios), APIClient: api.NewFailTestClient(), SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: attestation.ReleasePredicateType, + PredicateType: shared.ReleasePredicateType, HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, } - ec, err := attestation.NewEnforcementCriteria(opts) + ec, err := shared.NewEnforcementCriteria(opts) require.NoError(t, err) opts.EC = ec @@ -156,7 +155,7 @@ func Test_verifyRun_Failed_NoAttestation(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - opts := &attestation.AttestOptions{ + opts := &shared.AttestOptions{ TagName: tagName, Repo: "owner/repo", Owner: "owner", @@ -166,10 +165,10 @@ func Test_verifyRun_Failed_NoAttestation(t *testing.T) { SigstoreVerifier: verification.NewMockSigstoreVerifier(t), HttpClient: &http.Client{Transport: fakeHTTP}, BaseRepo: baseRepo, - PredicateType: attestation.ReleasePredicateType, + PredicateType: shared.ReleasePredicateType, } - ec, err := attestation.NewEnforcementCriteria(opts) + ec, err := shared.NewEnforcementCriteria(opts) require.NoError(t, err) opts.EC = ec From 5048d586dcc56f2edd1014dce25dede8d5e4b866 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 13:46:54 -0700 Subject: [PATCH 215/249] moved to shared lib --- pkg/cmd/release/shared/attestation.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/release/shared/attestation.go b/pkg/cmd/release/shared/attestation.go index bf2f39a7c..2d859d531 100644 --- a/pkg/cmd/release/shared/attestation.go +++ b/pkg/cmd/release/shared/attestation.go @@ -1,4 +1,4 @@ -package attestation +package shared import ( "errors" From d7d9228609e555b5541088e56c8f25bbbe1177db Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Fri, 30 May 2025 14:08:59 -0700 Subject: [PATCH 216/249] use standardize color roles logic for the logging --- pkg/cmd/release/shared/attestation.go | 8 ++++---- pkg/cmd/release/verify-asset/verify-asset.go | 12 ++++++------ pkg/cmd/release/verify/verify.go | 16 ++++++++-------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/release/shared/attestation.go b/pkg/cmd/release/shared/attestation.go index 2d859d531..a3aa3bea5 100644 --- a/pkg/cmd/release/shared/attestation.go +++ b/pkg/cmd/release/shared/attestation.go @@ -15,7 +15,7 @@ import ( func GetAttestations(o *AttestOptions, sha string) ([]*api.Attestation, string, error) { if o.APIClient == nil { - errMsg := "✗ No APIClient provided" + errMsg := "X No APIClient provided" return nil, errMsg, errors.New(errMsg) } @@ -29,7 +29,7 @@ func GetAttestations(o *AttestOptions, sha string) ([]*api.Attestation, string, attestations, err := o.APIClient.GetByDigest(params) if err != nil { - msg := "✗ Loading attestations from GitHub API failed" + msg := "X Loading attestations from GitHub API failed" return nil, msg, err } pluralAttestation := text.Pluralize(len(attestations), "attestation") @@ -40,13 +40,13 @@ func GetAttestations(o *AttestOptions, sha string) ([]*api.Attestation, string, func VerifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { sgPolicy, err := buildSigstoreVerifyPolicy(ec, art) if err != nil { - logMsg := "✗ Failed to build Sigstore verification policy" + logMsg := "X Failed to build Sigstore verification policy" return nil, logMsg, err } sigstoreVerified, err := sgVerifier.Verify(att, sgPolicy) if err != nil { - logMsg := "✗ Sigstore verification failed" + logMsg := "X Sigstore verification failed" return nil, logMsg, err } diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go index 4100d179e..260589d11 100644 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ b/pkg/cmd/release/verify-asset/verify-asset.go @@ -79,7 +79,7 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*shared.AttestOptions) erro RunE: func(cmd *cobra.Command, args []string) error { td, err := opts.APIClient.GetTrustDomain() if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to get trust domain")) return err } @@ -87,7 +87,7 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*shared.AttestOptions) erro ec, err := shared.NewEnforcementCriteria(opts) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to build policy information")) return err } @@ -121,7 +121,7 @@ func verifyAssetRun(opts *shared.AttestOptions) error { sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to create Sigstore verifier")) return err } @@ -141,7 +141,7 @@ func verifyAssetRun(opts *shared.AttestOptions) error { // calculate the digest of the file fileDigest, err := artifact.NewDigestedArtifact(nil, opts.AssetFilePath, "sha256") if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to calculate file digest")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to calculate file digest")) return err } @@ -158,7 +158,7 @@ func verifyAssetRun(opts *shared.AttestOptions) error { attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) return err } opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) @@ -198,7 +198,7 @@ func verifyAssetRun(opts *shared.AttestOptions) error { if opts.Exporter != nil { // print the results to the terminal as an array of JSON objects if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to write JSON output")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to write JSON output")) return err } return nil diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index ff8f7147e..b8276f989 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -73,14 +73,14 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *c RunE: func(cmd *cobra.Command, args []string) error { td, err := opts.APIClient.GetTrustDomain() if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to get trust domain")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to get trust domain")) return err } opts.TrustedRoot = td ec, err := shared.NewEnforcementCriteria(opts) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build policy information")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to build policy information")) return err } opts.EC = ec @@ -110,7 +110,7 @@ func verifyRun(opts *shared.AttestOptions) error { sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to create Sigstore verifier")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to create Sigstore verifier")) return err } @@ -137,7 +137,7 @@ func verifyRun(opts *shared.AttestOptions) error { attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) if err != nil { if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) return err } opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) @@ -152,7 +152,7 @@ func verifyRun(opts *shared.AttestOptions) error { } if len(filteredAttestations) == 0 { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for release %s in %s\n"), opts.TagName, opts.Repo) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for release %s in %s\n"), opts.TagName, opts.Repo) return fmt.Errorf("no attestations found for release %s in %s", opts.TagName, opts.Repo) } @@ -163,7 +163,7 @@ func verifyRun(opts *shared.AttestOptions) error { if err != nil { opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ Failed to find an attestation for release %s in %s\n"), opts.TagName, opts.Repo) + opts.Logger.Printf(opts.Logger.ColorScheme.Red("X Failed to find an attestation for release %s in %s\n"), opts.TagName, opts.Repo) return err } @@ -171,7 +171,7 @@ func verifyRun(opts *shared.AttestOptions) error { if opts.Exporter != nil { // print the results to the terminal as an array of JSON objects if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to write JSON output")) + opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to write JSON output")) return err } return nil @@ -192,7 +192,7 @@ func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, var statementData v1.Statement err := protojson.Unmarshal([]byte(statement), &statementData) if err != nil { - logger.Println(logger.ColorScheme.Red("✗ Failed to unmarshal statement")) + logger.Println(logger.ColorScheme.Red("X Failed to unmarshal statement")) continue } for _, s := range statementData.Subject { From 793a7ff459ba7b121c4c2f21186b983b73d69fa9 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 2 Jun 2025 14:33:38 +0200 Subject: [PATCH 217/249] Fix pr edit when URL is provided --- api/queries_pr.go | 28 ++++++++---- pkg/cmd/pr/edit/edit.go | 26 +---------- pkg/cmd/pr/edit/edit_test.go | 24 +++++++--- pkg/cmd/pr/shared/finder.go | 31 +++++++++++++ pkg/cmd/pr/shared/finder_test.go | 76 ++++++++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 39 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 525418a11..1d394e864 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -85,15 +85,25 @@ type PullRequest struct { Assignees Assignees AssignedActors AssignedActors - Labels Labels - ProjectCards ProjectCards - ProjectItems ProjectItems - Milestone *Milestone - Comments Comments - ReactionGroups ReactionGroups - Reviews PullRequestReviews - LatestReviews PullRequestReviews - ReviewRequests ReviewRequests + // AssignedActorsUsed is a GIGANTIC hack to carry around whether we expected AssignedActors to be requested + // on this PR. This is required because the Feature Detection of support for AssignedActors occurs inside the + // PR Finder, but knowledge of support is required at the command level. However, we can't easily construct + // the feature detector at the command level because it needs knowledge of the BaseRepo, which is only available + // inside the PR Finder. This is bad and we should feel bad. + // + // The right solution is to extract argument parsing from the PR Finder into each command, so that we have access + // to the BaseRepo and can construct the feature detector there. This is what happens in the issue commands with + // `shared.ParseIssueFromArg`. + AssignedActorsUsed bool + Labels Labels + ProjectCards ProjectCards + ProjectItems ProjectItems + Milestone *Milestone + Comments Comments + ReactionGroups ReactionGroups + Reviews PullRequestReviews + LatestReviews PullRequestReviews + ReviewRequests ReviewRequests ClosingIssuesReferences ClosingIssuesReferences } diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index c30c4f9bb..867ee9a4f 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -3,7 +3,6 @@ package edit import ( "fmt" "net/http" - "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" @@ -206,7 +205,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman func editRun(opts *EditOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, - Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "labels", "projectCards", "projectItems", "milestone"}, + Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "labels", "projectCards", "projectItems", "milestone", "assignees"}, Detector: opts.Detector, } @@ -215,27 +214,6 @@ func editRun(opts *EditOptions) error { return err } - if opts.Detector == nil { - baseRepo, err := opts.BaseRepo() - if err != nil { - return err - } - - cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) - } - - issueFeatures, err := opts.Detector.IssueFeatures() - if err != nil { - return err - } - - if issueFeatures.ActorIsAssignable { - findOptions.Fields = append(findOptions.Fields, "assignedActors") - } else { - findOptions.Fields = append(findOptions.Fields, "assignees") - } - pr, repo, err := opts.Finder.Find(findOptions) if err != nil { return err @@ -247,7 +225,7 @@ func editRun(opts *EditOptions) error { editable.Body.Default = pr.Body editable.Base.Default = pr.BaseRefName editable.Reviewers.Default = pr.ReviewRequests.Logins() - if issueFeatures.ActorIsAssignable { + if pr.AssignedActorsUsed { editable.Assignees.ActorAssignees = true editable.Assignees.Default = pr.AssignedActors.DisplayNames() } else { diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 3a1e8a548..0c40e3839 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -341,9 +341,11 @@ func Test_editRun(t *testing.T) { { name: "non-interactive", input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, SelectorArg: "123", Finder: shared.NewMockFinder("123", &api.PullRequest{ - URL: "https://github.com/OWNER/REPO/pull/123", + URL: "https://github.com/OWNER/REPO/pull/123", + AssignedActorsUsed: true, }, ghrepo.New("OWNER", "REPO")), Interactive: false, Editable: shared.Editable{ @@ -403,9 +405,11 @@ func Test_editRun(t *testing.T) { { name: "non-interactive skip reviewers", input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, SelectorArg: "123", Finder: shared.NewMockFinder("123", &api.PullRequest{ - URL: "https://github.com/OWNER/REPO/pull/123", + URL: "https://github.com/OWNER/REPO/pull/123", + AssignedActorsUsed: true, }, ghrepo.New("OWNER", "REPO")), Interactive: false, Editable: shared.Editable{ @@ -459,9 +463,11 @@ func Test_editRun(t *testing.T) { { name: "non-interactive remove all reviewers", input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, SelectorArg: "123", Finder: shared.NewMockFinder("123", &api.PullRequest{ - URL: "https://github.com/OWNER/REPO/pull/123", + URL: "https://github.com/OWNER/REPO/pull/123", + AssignedActorsUsed: true, }, ghrepo.New("OWNER", "REPO")), Interactive: false, Editable: shared.Editable{ @@ -520,9 +526,11 @@ func Test_editRun(t *testing.T) { { name: "interactive", input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, SelectorArg: "123", Finder: shared.NewMockFinder("123", &api.PullRequest{ - URL: "https://github.com/OWNER/REPO/pull/123", + URL: "https://github.com/OWNER/REPO/pull/123", + AssignedActorsUsed: true, }, ghrepo.New("OWNER", "REPO")), Interactive: true, Surveyor: testSurveyor{}, @@ -542,9 +550,11 @@ func Test_editRun(t *testing.T) { { name: "interactive skip reviewers", input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, SelectorArg: "123", Finder: shared.NewMockFinder("123", &api.PullRequest{ - URL: "https://github.com/OWNER/REPO/pull/123", + URL: "https://github.com/OWNER/REPO/pull/123", + AssignedActorsUsed: true, }, ghrepo.New("OWNER", "REPO")), Interactive: true, Surveyor: testSurveyor{skipReviewers: true}, @@ -563,9 +573,11 @@ func Test_editRun(t *testing.T) { { name: "interactive remove all reviewers", input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, SelectorArg: "123", Finder: shared.NewMockFinder("123", &api.PullRequest{ - URL: "https://github.com/OWNER/REPO/pull/123", + URL: "https://github.com/OWNER/REPO/pull/123", + AssignedActorsUsed: true, }, ghrepo.New("OWNER", "REPO")), Interactive: true, Surveyor: testSurveyor{removeAllReviewers: true}, diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index b8d23c978..a19c69669 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -242,6 +242,33 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err } } + // Ok this is super, super horrible so bear with me. + // The `assignees` field on a Pull Request exposes users that are assigned. It is also possible for bots to be + // assigned, but they only appear under the `assignedActors` field. Ideally, the caller of `Find` would determine + // the correct field to use based on the `fd.Detector` that is passed in, but they can't construct a detector + // because the BaseRepo is only determined within this function. The more correct solution is to do what I did with + // the issue commands and decouple argument parsing from API lookup. See PR #10811 for example. + var actorAssigneesUsed bool + if fields.Contains("assignees") { + if opts.Detector == nil { + cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) + opts.Detector = fd.NewDetector(cachedClient, f.baseRefRepo.RepoHost()) + } + + issueFeatures, err := opts.Detector.IssueFeatures() + if err != nil { + return nil, nil, fmt.Errorf("error detecting issue features: %v", err) + } + + // If actors are assignable on this host then we additionally request the `assignedActors` field. + // Note that we don't remove the `assignees` field because some commands (`pr view`) do not display actor + // assignees yet, so we have to have both sets of data. + if issueFeatures.ActorIsAssignable { + fields.Add("assignedActors") + actorAssigneesUsed = true + } + } + var pr *api.PullRequest if f.prNumber > 0 { // If we have a PR number, let's look it up @@ -297,6 +324,10 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err }) } + if actorAssigneesUsed { + pr.AssignedActorsUsed = true + } + return pr, f.baseRefRepo, g.Wait() } diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index abc754d1a..28c2337d3 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -9,6 +9,7 @@ import ( 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/httpmock" "github.com/stretchr/testify/require" @@ -705,6 +706,81 @@ func TestFind(t *testing.T) { } } +func TestFindAssignableActors(t *testing.T) { + t.Run("given actors are not assignable, do nothing special", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // Ensure we never request assignedActors + reg.Exclude(t, httpmock.GraphQL(`assignedActors`)) + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequest":{"number":13} + }}}`)) + + f := finder{ + httpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + } + + pr, _, err := f.Find(FindOptions{ + Detector: &fd.DisabledDetectorMock{}, + Fields: []string{"assignees"}, + Selector: "https://github.com/cli/cli/pull/123", + }) + require.NoError(t, err) + + require.False(t, pr.AssignedActorsUsed, "expected PR not to have assigned actors used") + }) + + t.Run("given actors are assignable, request assignedActors and indicate that on the returned PR", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // Ensure that we only respond if assignedActors is requested + reg.Register( + httpmock.GraphQL(`assignedActors`), + httpmock.StringResponse(`{"data":{"repository":{ + "pullRequest":{ + "number":13, + "assignedActors": { + "nodes": [ + { + "id": "HUBOTID", + "login": "hubot", + "__typename": "Bot" + }, + { + "id": "MONAID", + "login": "MonaLisa", + "name": "Mona Display Name", + "__typename": "User" + } + ], + "totalCount": 2 + }} + }}}`)) + + f := finder{ + httpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + } + + pr, _, err := f.Find(FindOptions{ + Detector: &fd.EnabledDetectorMock{}, + Fields: []string{"assignees"}, + Selector: "https://github.com/cli/cli/pull/123", + }) + require.NoError(t, err) + + require.Equal(t, []string{"hubot", "MonaLisa"}, pr.AssignedActors.Logins()) + require.True(t, pr.AssignedActorsUsed, "expected PR to have assigned actors used") + }) +} + 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 From 9a8031151ce5475cd2393350ad77b4886596d170 Mon Sep 17 00:00:00 2001 From: Tim Rogers Date: Mon, 2 Jun 2025 16:29:35 +0100 Subject: [PATCH 218/249] Document support for `@copilot` in `gh [pr|issue] edit --add-assignee` and `--remove-assignee` (#11056) * Document support for `@copilot` in `gh [pr|issue] edit --add-assignee` and `--remove-assignee` Following up on #10991, this updates the help text for `issue edit` and `pr edit`'s `--add-assignee` and `--remove-assignee` options to mention that you can use `@copilot`. This is already mentioned in the command-level help text, but not at the argument level, whereas the `@me` macro is. * Apply suggestion from @babakks Co-authored-by: Babak K. Shandiz * Apply suggestion from @babakks Co-authored-by: Babak K. Shandiz --------- Co-authored-by: Babak K. Shandiz --- pkg/cmd/issue/edit/edit.go | 4 ++-- pkg/cmd/pr/edit/edit.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index e959cde2b..b207a96fd 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -171,8 +171,8 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.Editable.Title.Value, "title", "t", "", "Set the new title.") cmd.Flags().StringVarP(&opts.Editable.Body.Value, "body", "b", "", "Set the new body.") cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)") - cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Add, "add-assignee", nil, "Add assigned users by their `login`. Use \"@me\" to assign yourself.") - cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Remove, "remove-assignee", nil, "Remove assigned users by their `login`. Use \"@me\" to unassign yourself.") + cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Add, "add-assignee", nil, "Add assigned users by their `login`. Use \"@me\" to assign yourself, or \"@copilot\" to assign Copilot.") + cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Remove, "remove-assignee", nil, "Remove assigned users by their `login`. Use \"@me\" to unassign yourself, or \"@copilot\" to unassign Copilot.") cmd.Flags().StringSliceVar(&opts.Editable.Labels.Add, "add-label", nil, "Add labels by `name`") cmd.Flags().StringSliceVar(&opts.Editable.Labels.Remove, "remove-label", nil, "Remove labels by `name`") cmd.Flags().StringSliceVar(&opts.Editable.Projects.Add, "add-project", nil, "Add the issue to projects by `title`") diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index c30c4f9bb..072356a1a 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -171,8 +171,8 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.Editable.Base.Value, "base", "B", "", "Change the base `branch` for this pull request") cmd.Flags().StringSliceVar(&opts.Editable.Reviewers.Add, "add-reviewer", nil, "Add reviewers by their `login`.") cmd.Flags().StringSliceVar(&opts.Editable.Reviewers.Remove, "remove-reviewer", nil, "Remove reviewers by their `login`.") - cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Add, "add-assignee", nil, "Add assigned users by their `login`. Use \"@me\" to assign yourself.") - cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Remove, "remove-assignee", nil, "Remove assigned users by their `login`. Use \"@me\" to unassign yourself.") + cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Add, "add-assignee", nil, "Add assigned users by their `login`. Use \"@me\" to assign yourself, or \"@copilot\" to assign Copilot.") + cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Remove, "remove-assignee", nil, "Remove assigned users by their `login`. Use \"@me\" to unassign yourself, or \"@copilot\" to unassign Copilot.") cmd.Flags().StringSliceVar(&opts.Editable.Labels.Add, "add-label", nil, "Add labels by `name`") cmd.Flags().StringSliceVar(&opts.Editable.Labels.Remove, "remove-label", nil, "Remove labels by `name`") cmd.Flags().StringSliceVar(&opts.Editable.Projects.Add, "add-project", nil, "Add the pull request to projects by `title`") From cde860be883b444e9151b3c560eea5c28420d871 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Tue, 3 Jun 2025 13:33:52 +0100 Subject: [PATCH 219/249] Iterate on `pr edit` problems with existing assignees This commit is a handful of changes around `gh pr edit --add-assignee` behavior. Most notably, fixing a bug where the assigned actors weren't being dropped. In addition to this, I was refactoring the testing setup to allow for individual test table scenarios could be contained. --- pkg/cmd/issue/edit/edit_test.go | 17 ++- pkg/cmd/pr/edit/edit.go | 1 + pkg/cmd/pr/edit/edit_test.go | 177 ++++++++++++++++++++++++-------- 3 files changed, 144 insertions(+), 51 deletions(-) diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 4840cbf7a..7d2d36662 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -631,10 +631,11 @@ func Test_editRun(t *testing.T) { }, EditFieldsSurvey: func(p prShared.EditPrompter, eo *prShared.Editable, _ string) error { // Checking that the display name is being used in the prompt. - require.Equal(t, eo.Assignees.Default, []string{"hubot", "MonaLisa (Mona Display Name)"}) + require.Equal(t, eo.Assignees.Default, []string{"hubot"}) + require.Equal(t, eo.Assignees.DefaultLogins, []string{"hubot"}) - // Mocking a selection of only MonaLisa in the prompt. - eo.Assignees.Value = []string{"MonaLisa (Mona Display Name)"} + // Adding MonaLisa as PR assignee, should preserve hubot. + eo.Assignees.Value = []string{"hubot", "MonaLisa (Mona Display Name)"} return nil }, FetchOptions: prShared.FetchOptions, @@ -662,7 +663,7 @@ func Test_editRun(t *testing.T) { // Checking that despite the display name being returned // from the EditFieldsSurvey, the ID is still // used in the mutation. - require.Contains(t, inputs["actorIds"], "MONAID") + require.Subset(t, inputs["actorIds"], []string{"MONAID", "HUBOTID"}) }), ) }, @@ -809,15 +810,9 @@ func mockIsssueNumberGetWithAssignedActors(_ *testing.T, reg *httpmock.Registry, "id": "HUBOTID", "login": "hubot", "__typename": "Bot" - }, - { - "id": "MONAID", - "login": "MonaLisa", - "name": "Mona Display Name", - "__typename": "User" } ], - "totalCount": 2 + "totalCount": 1 } } } } }`, number)), ) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 5e1f19b18..becbfce47 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -228,6 +228,7 @@ func editRun(opts *EditOptions) error { if pr.AssignedActorsUsed { editable.Assignees.ActorAssignees = true editable.Assignees.Default = pr.AssignedActors.DisplayNames() + editable.Assignees.DefaultLogins = pr.AssignedActors.Logins() } else { editable.Assignees.Default = pr.Assignees.Logins() } diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 0c40e3839..0b731cd00 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -532,8 +532,31 @@ func Test_editRun(t *testing.T) { URL: "https://github.com/OWNER/REPO/pull/123", AssignedActorsUsed: true, }, ghrepo.New("OWNER", "REPO")), - Interactive: true, - Surveyor: testSurveyor{}, + Interactive: true, + Surveyor: testSurveyor{ + fieldsToEdit: func(e *shared.Editable) error { + e.Title.Edited = true + e.Body.Edited = true + e.Reviewers.Edited = true + e.Assignees.Edited = true + e.Labels.Edited = true + e.Projects.Edited = true + e.Milestone.Edited = true + return nil + }, + editFields: func(e *shared.Editable, _ string) error { + e.Title.Value = "new title" + e.Body.Value = "new body" + e.Reviewers.Value = []string{"monalisa", "hubot", "OWNER/core", "OWNER/external"} + e.Assignees.Value = []string{"monalisa", "hubot"} + e.Labels.Value = []string{"feature", "TODO", "bug"} + e.Labels.Add = []string{"feature", "TODO", "bug"} + e.Labels.Remove = []string{"docs"} + e.Projects.Value = []string{"Cleanup", "CleanupV2"} + e.Milestone.Value = "GA" + return nil + }, + }, Fetcher: testFetcher{}, EditorRetriever: testEditorRetriever{}, }, @@ -556,8 +579,29 @@ func Test_editRun(t *testing.T) { URL: "https://github.com/OWNER/REPO/pull/123", AssignedActorsUsed: true, }, ghrepo.New("OWNER", "REPO")), - Interactive: true, - Surveyor: testSurveyor{skipReviewers: true}, + Interactive: true, + Surveyor: testSurveyor{ + fieldsToEdit: func(e *shared.Editable) error { + e.Title.Edited = true + e.Body.Edited = true + e.Assignees.Edited = true + e.Labels.Edited = true + e.Projects.Edited = true + e.Milestone.Edited = true + return nil + }, + editFields: func(e *shared.Editable, _ string) error { + e.Title.Value = "new title" + e.Body.Value = "new body" + e.Assignees.Value = []string{"monalisa", "hubot"} + e.Labels.Value = []string{"feature", "TODO", "bug"} + e.Labels.Add = []string{"feature", "TODO", "bug"} + e.Labels.Remove = []string{"docs"} + e.Projects.Value = []string{"Cleanup", "CleanupV2"} + e.Milestone.Value = "GA" + return nil + }, + }, Fetcher: testFetcher{}, EditorRetriever: testEditorRetriever{}, }, @@ -579,8 +623,30 @@ func Test_editRun(t *testing.T) { URL: "https://github.com/OWNER/REPO/pull/123", AssignedActorsUsed: true, }, ghrepo.New("OWNER", "REPO")), - Interactive: true, - Surveyor: testSurveyor{removeAllReviewers: true}, + Interactive: true, + Surveyor: testSurveyor{ + fieldsToEdit: func(e *shared.Editable) error { + e.Title.Edited = true + e.Body.Edited = true + e.Assignees.Edited = true + e.Labels.Edited = true + e.Projects.Edited = true + e.Milestone.Edited = true + return nil + }, + editFields: func(e *shared.Editable, _ string) error { + e.Title.Value = "new title" + e.Body.Value = "new body" + e.Reviewers.Remove = []string{"monalisa", "hubot", "OWNER/core", "OWNER/external", "dependabot"} + e.Assignees.Value = []string{"monalisa", "hubot"} + e.Labels.Value = []string{"feature", "TODO", "bug"} + e.Labels.Add = []string{"feature", "TODO", "bug"} + e.Labels.Remove = []string{"docs"} + e.Projects.Value = []string{"Cleanup", "CleanupV2"} + e.Milestone.Value = "GA" + return nil + }, + }, Fetcher: testFetcher{}, EditorRetriever: testEditorRetriever{}, }, @@ -659,6 +725,59 @@ func Test_editRun(t *testing.T) { } } +func Test_editRun_actors(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.GraphQL(`query RepositoryAssignableActors\b`), + httpmock.StringResponse(` + { "data": { "repository": { "suggestedActors": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID", "__typename": "Bot" }, + { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + mockPullRequestUpdate(reg) + mockPullRequestUpdateActorAssignees(reg) + + httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } + + input := &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + SelectorArg: "123", + Finder: shared.NewMockFinder("123", &api.PullRequest{ + URL: "https://github.com/OWNER/REPO/pull/123", + }, ghrepo.New("OWNER", "REPO")), + Interactive: false, + Editable: shared.Editable{ + Assignees: shared.EditableAssignees{ + EditableSlice: shared.EditableSlice{ + Add: []string{"monalisa", "hubot"}, + Remove: []string{"octocat"}, + Edited: true, + }, + }, + }, + Fetcher: testFetcher{}, + } + input.IO = ios + input.HttpClient = httpClient + input.BaseRepo = baseRepo + + err := editRun(input) + assert.NoError(t, err) + assert.Equal(t, "https://github.com/OWNER/REPO/pull/123\n", stdout.String()) + assert.Equal(t, "", stderr.String()) +} + func mockRepoMetadata(reg *httpmock.Registry, skipReviewers bool) { reg.Register( httpmock.GraphQL(`query RepositoryAssignableActors\b`), @@ -666,7 +785,7 @@ func mockRepoMetadata(reg *httpmock.Registry, skipReviewers bool) { { "data": { "repository": { "suggestedActors": { "nodes": [ { "login": "hubot", "id": "HUBOTID", "__typename": "Bot" }, - { "login": "MonaLisa", "id": "MONAID", "__typename": "User" } + { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" } ], "pageInfo": { "hasNextPage": false } } } } } @@ -813,48 +932,26 @@ func mockProjectV2ItemUpdate(reg *httpmock.Registry) { } type testFetcher struct{} -type testSurveyor struct { - skipReviewers bool - removeAllReviewers bool -} -type testEditorRetriever struct{} func (f testFetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable) error { return shared.FetchOptions(client, repo, opts) } -func (s testSurveyor) FieldsToEdit(e *shared.Editable) error { - e.Title.Edited = true - e.Body.Edited = true - if !s.skipReviewers { - e.Reviewers.Edited = true - } - e.Assignees.Edited = true - e.Labels.Edited = true - e.Projects.Edited = true - e.Milestone.Edited = true - return nil +type testSurveyor struct { + fieldsToEdit func(e *shared.Editable) error + editFields func(e *shared.Editable, editorCmd string) error } -func (s testSurveyor) EditFields(e *shared.Editable, _ string) error { - e.Title.Value = "new title" - e.Body.Value = "new body" - if !s.skipReviewers { - if s.removeAllReviewers { - e.Reviewers.Remove = []string{"monalisa", "hubot", "OWNER/core", "OWNER/external", "dependabot"} - } else { - e.Reviewers.Value = []string{"monalisa", "hubot", "OWNER/core", "OWNER/external"} - } - } - e.Assignees.Value = []string{"monalisa", "hubot"} - e.Labels.Value = []string{"feature", "TODO", "bug"} - e.Labels.Add = []string{"feature", "TODO", "bug"} - e.Labels.Remove = []string{"docs"} - e.Projects.Value = []string{"Cleanup", "CleanupV2"} - e.Milestone.Value = "GA" - return nil +func (s testSurveyor) FieldsToEdit(e *shared.Editable) error { + return s.fieldsToEdit(e) } +func (s testSurveyor) EditFields(e *shared.Editable, editorCmd string) error { + return s.editFields(e, editorCmd) +} + +type testEditorRetriever struct{} + func (t testEditorRetriever) Retrieve() (string, error) { return "vim", nil } From a24d39ac87b14979f2a52e3f417f92d17e53186e Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Tue, 3 Jun 2025 13:48:01 +0100 Subject: [PATCH 220/249] Fix test, remove partial standalone test --- pkg/cmd/pr/edit/edit_test.go | 54 +----------------------------------- 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 0b731cd00..99f949e8b 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -628,6 +628,7 @@ func Test_editRun(t *testing.T) { fieldsToEdit: func(e *shared.Editable) error { e.Title.Edited = true e.Body.Edited = true + e.Reviewers.Edited = true e.Assignees.Edited = true e.Labels.Edited = true e.Projects.Edited = true @@ -725,59 +726,6 @@ func Test_editRun(t *testing.T) { } } -func Test_editRun_actors(t *testing.T) { - ios, _, stdout, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStdinTTY(true) - ios.SetStderrTTY(true) - - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.GraphQL(`query RepositoryAssignableActors\b`), - httpmock.StringResponse(` - { "data": { "repository": { "suggestedActors": { - "nodes": [ - { "login": "hubot", "id": "HUBOTID", "__typename": "Bot" }, - { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" } - ], - "pageInfo": { "hasNextPage": false } - } } } } - `)) - mockPullRequestUpdate(reg) - mockPullRequestUpdateActorAssignees(reg) - - httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } - baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } - - input := &EditOptions{ - Detector: &fd.EnabledDetectorMock{}, - SelectorArg: "123", - Finder: shared.NewMockFinder("123", &api.PullRequest{ - URL: "https://github.com/OWNER/REPO/pull/123", - }, ghrepo.New("OWNER", "REPO")), - Interactive: false, - Editable: shared.Editable{ - Assignees: shared.EditableAssignees{ - EditableSlice: shared.EditableSlice{ - Add: []string{"monalisa", "hubot"}, - Remove: []string{"octocat"}, - Edited: true, - }, - }, - }, - Fetcher: testFetcher{}, - } - input.IO = ios - input.HttpClient = httpClient - input.BaseRepo = baseRepo - - err := editRun(input) - assert.NoError(t, err) - assert.Equal(t, "https://github.com/OWNER/REPO/pull/123\n", stdout.String()) - assert.Equal(t, "", stderr.String()) -} - func mockRepoMetadata(reg *httpmock.Registry, skipReviewers bool) { reg.Register( httpmock.GraphQL(`query RepositoryAssignableActors\b`), From ed4b90104f290148229837324a2d2b63ad79be08 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:20:37 +0100 Subject: [PATCH 221/249] test(pr): Add tests for actor assignees --- pkg/cmd/issue/edit/edit_test.go | 4 +- pkg/cmd/pr/edit/edit_test.go | 65 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 7d2d36662..d14b2f462 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -631,8 +631,8 @@ func Test_editRun(t *testing.T) { }, EditFieldsSurvey: func(p prShared.EditPrompter, eo *prShared.Editable, _ string) error { // Checking that the display name is being used in the prompt. - require.Equal(t, eo.Assignees.Default, []string{"hubot"}) - require.Equal(t, eo.Assignees.DefaultLogins, []string{"hubot"}) + require.Equal(t, []string{"hubot"}, eo.Assignees.Default) + require.Equal(t, []string{"hubot"}, eo.Assignees.DefaultLogins) // Adding MonaLisa as PR assignee, should preserve hubot. eo.Assignees.Value = []string{"hubot", "MonaLisa (Mona Display Name)"} diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 99f949e8b..374625912 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -661,6 +661,71 @@ func Test_editRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/pull/123\n", }, + { + name: "interactive prompts with actor assignee display names when actors available", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + SelectorArg: "123", + Finder: shared.NewMockFinder("123", &api.PullRequest{ + URL: "https://github.com/OWNER/REPO/pull/123", + AssignedActorsUsed: true, + AssignedActors: api.AssignedActors{ + Nodes: []api.Actor{ + { + ID: "HUBOTID", + Login: "hubot", + TypeName: "Bot", + }, + }, + TotalCount: 1, + }, + }, ghrepo.New("OWNER", "REPO")), + Interactive: true, + Surveyor: testSurveyor{ + fieldsToEdit: func(e *shared.Editable) error { + e.Assignees.Edited = true + return nil + }, + editFields: func(e *shared.Editable, _ string) error { + // Checking that the display name is being used in the prompt. + require.Equal(t, []string{"hubot"}, e.Assignees.Default) + require.Equal(t, []string{"hubot"}, e.Assignees.DefaultLogins) + + // Adding MonaLisa as PR assignee, should preserve hubot. + e.Assignees.Value = []string{"hubot", "MonaLisa (Mona Display Name)"} + return nil + }, + }, + Fetcher: testFetcher{}, + EditorRetriever: testEditorRetriever{}, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryAssignableActors\b`), + httpmock.StringResponse(` + { "data": { "repository": { "suggestedActors": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID", "__typename": "Bot" }, + { "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + mockPullRequestUpdate(reg) + reg.Register( + httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`), + httpmock.GraphQLMutation(` + { "data": { "replaceActorsForAssignable": { "__typename": "" } } }`, + func(inputs map[string]interface{}) { + // Checking that despite the display name being returned + // from the EditFieldsSurvey, the ID is still + // used in the mutation. + require.Subset(t, inputs["actorIds"], []string{"MONAID", "HUBOTID"}) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/pull/123\n", + }, { name: "Legacy assignee users are fetched and updated on unsupported GitHub Hosts", input: &EditOptions{ From 2266c7a5b527d32bb80939a52bddaf99dc797db0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 14:57:13 +0000 Subject: [PATCH 222/249] chore(deps): bump mislav/bump-homebrew-formula-action from 3.2 to 3.4 Bumps [mislav/bump-homebrew-formula-action](https://github.com/mislav/bump-homebrew-formula-action) from 3.2 to 3.4. - [Release notes](https://github.com/mislav/bump-homebrew-formula-action/releases) - [Commits](https://github.com/mislav/bump-homebrew-formula-action/compare/942e550c6344cfdb9e1ab29b9bb9bf0c43efa19b...8e2baa47daaa8db10fcdeb04105dfa6850eb0d68) --- updated-dependencies: - dependency-name: mislav/bump-homebrew-formula-action dependency-version: '3.4' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/deployment.yml | 2 +- .github/workflows/homebrew-bump.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 850cc19b7..d5cc67ec9 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -384,7 +384,7 @@ jobs: git diff --name-status @{upstream}.. fi - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@942e550c6344cfdb9e1ab29b9bb9bf0c43efa19b + uses: mislav/bump-homebrew-formula-action@8e2baa47daaa8db10fcdeb04105dfa6850eb0d68 if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') with: formula-name: gh diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml index 228f1a345..0b42803aa 100644 --- a/.github/workflows/homebrew-bump.yml +++ b/.github/workflows/homebrew-bump.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@942e550c6344cfdb9e1ab29b9bb9bf0c43efa19b + uses: mislav/bump-homebrew-formula-action@8e2baa47daaa8db10fcdeb04105dfa6850eb0d68 if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') with: formula-name: gh From 53cae592f616fbefac8672cfb97d2e1da6505b7d Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Thu, 5 Jun 2025 10:05:46 -0700 Subject: [PATCH 223/249] refactor to simplify implementation Signed-off-by: Brian DeHamer --- pkg/cmd/attestation/artifact/file.go | 2 +- pkg/cmd/release/release.go | 1 - pkg/cmd/release/shared/attestation.go | 95 ++++--- pkg/cmd/release/shared/options.go | 76 ----- pkg/cmd/release/shared/options_test.go | 60 ---- pkg/cmd/release/shared/policy.go | 76 ----- pkg/cmd/release/shared/policy_test.go | 71 ----- pkg/cmd/release/verify-asset/verify-asset.go | 219 -------------- .../release/verify-asset/verify-asset_test.go | 230 --------------- pkg/cmd/release/verify-asset/verify_asset.go | 182 ++++++++++++ .../release/verify-asset/verify_asset_test.go | 267 ++++++++++++++++++ pkg/cmd/release/verify/verify.go | 242 ++++++++-------- pkg/cmd/release/verify/verify_test.go | 158 +++++------ 13 files changed, 705 insertions(+), 974 deletions(-) delete mode 100644 pkg/cmd/release/shared/options.go delete mode 100644 pkg/cmd/release/shared/options_test.go delete mode 100644 pkg/cmd/release/shared/policy.go delete mode 100644 pkg/cmd/release/shared/policy_test.go delete mode 100644 pkg/cmd/release/verify-asset/verify-asset.go delete mode 100644 pkg/cmd/release/verify-asset/verify-asset_test.go create mode 100644 pkg/cmd/release/verify-asset/verify_asset.go create mode 100644 pkg/cmd/release/verify-asset/verify_asset_test.go diff --git a/pkg/cmd/attestation/artifact/file.go b/pkg/cmd/attestation/artifact/file.go index 789a92a5d..237a9bbf7 100644 --- a/pkg/cmd/attestation/artifact/file.go +++ b/pkg/cmd/attestation/artifact/file.go @@ -10,7 +10,7 @@ import ( func digestLocalFileArtifact(filename, digestAlg string) (*DigestedArtifact, error) { data, err := os.Open(filename) if err != nil { - return nil, fmt.Errorf("failed to get open local artifact: %v", err) + return nil, fmt.Errorf("failed to open local artifact: %v", err) } defer data.Close() digest, err := digest.CalculateDigestWithAlgorithm(data, digestAlg) diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index f25e8bd3a..f56042c81 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -10,7 +10,6 @@ import ( cmdUpload "github.com/cli/cli/v2/pkg/cmd/release/upload" cmdVerify "github.com/cli/cli/v2/pkg/cmd/release/verify" cmdVerifyAsset "github.com/cli/cli/v2/pkg/cmd/release/verify-asset" - cmdView "github.com/cli/cli/v2/pkg/cmd/release/view" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" diff --git a/pkg/cmd/release/shared/attestation.go b/pkg/cmd/release/shared/attestation.go index a3aa3bea5..4e0377fed 100644 --- a/pkg/cmd/release/shared/attestation.go +++ b/pkg/cmd/release/shared/attestation.go @@ -1,56 +1,58 @@ package shared import ( - "errors" "fmt" + "net/http" - "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/test/data" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" + "github.com/sigstore/sigstore-go/pkg/verify" v1 "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/encoding/protojson" ) -func GetAttestations(o *AttestOptions, sha string) ([]*api.Attestation, string, error) { - if o.APIClient == nil { - errMsg := "X No APIClient provided" - return nil, errMsg, errors.New(errMsg) - } +const ReleasePredicateType = "https://in-toto.io/attestation/release/v0.1" - params := api.FetchParams{ - Digest: sha, - Limit: o.Limit, - Owner: o.Owner, - PredicateType: o.PredicateType, - Repo: o.Repo, - } - - attestations, err := o.APIClient.GetByDigest(params) - if err != nil { - msg := "X Loading attestations from GitHub API failed" - return nil, msg, err - } - pluralAttestation := text.Pluralize(len(attestations), "attestation") - msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation) - return attestations, msg, nil +type Verifier interface { + // VerifyAttestation verifies the attestation for a given artifact + VerifyAttestation(art *artifact.DigestedArtifact, att *api.Attestation) (*verification.AttestationProcessingResult, error) } -func VerifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) { - sgPolicy, err := buildSigstoreVerifyPolicy(ec, art) +type AttestationVerifier struct { + AttClient api.Client + HttpClient *http.Client + IO *iostreams.IOStreams +} + +func (v *AttestationVerifier) VerifyAttestation(art *artifact.DigestedArtifact, att *api.Attestation) (*verification.AttestationProcessingResult, error) { + td, err := v.AttClient.GetTrustDomain() if err != nil { - logMsg := "X Failed to build Sigstore verification policy" - return nil, logMsg, err + return nil, err } - sigstoreVerified, err := sgVerifier.Verify(att, sgPolicy) + verifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ + HttpClient: v.HttpClient, + Logger: att_io.NewHandler(v.IO), + NoPublicGood: true, + TrustDomain: td, + }) if err != nil { - logMsg := "X Sigstore verification failed" - return nil, logMsg, err + return nil, err } - return sigstoreVerified, "", nil + policy := buildVerificationPolicy(*art) + sigstoreVerified, err := verifier.Verify([]*api.Attestation{att}, policy) + if err != nil { + return nil, err + } + + return sigstoreVerified[0], nil } func FilterAttestationsByTag(attestations []*api.Attestation, tagName string) ([]*api.Attestation, error) { @@ -71,7 +73,7 @@ func FilterAttestationsByTag(attestations []*api.Attestation, tagName string) ([ return filtered, nil } -func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagName, fileDigest string) ([]*api.Attestation, error) { +func FilterAttestationsByFileDigest(attestations []*api.Attestation, fileDigest string) ([]*api.Attestation, error) { var filtered []*api.Attestation for _, att := range attestations { statement := att.Bundle.Bundle.GetDsseEnvelope().Payload @@ -95,3 +97,32 @@ func FilterAttestationsByFileDigest(attestations []*api.Attestation, repo, tagNa } return filtered, nil } + +// buildVerificationPolicy constructs a verification policy for GitHub releases +func buildVerificationPolicy(a artifact.DigestedArtifact) verify.PolicyBuilder { + // SAN must match the GitHub releases domain. No issuer extension (match anything) + sanMatcher, _ := verify.NewSANMatcher("", "^https://.*\\.releases\\.github\\.com$") + issuerMatcher, _ := verify.NewIssuerMatcher("", ".*") + certId, _ := verify.NewCertificateIdentity(sanMatcher, issuerMatcher, certificate.Extensions{}) + + artifactDigestPolicyOption, _ := verification.BuildDigestPolicyOption(a) + return verify.NewPolicy(artifactDigestPolicyOption, verify.WithCertificateIdentity(certId)) +} + +type MockVerifier struct { + mockResult *verification.AttestationProcessingResult +} + +func NewMockVerifier(mockResult *verification.AttestationProcessingResult) *MockVerifier { + return &MockVerifier{mockResult: mockResult} +} + +func (v *MockVerifier) VerifyAttestation(art *artifact.DigestedArtifact, att *api.Attestation) (*verification.AttestationProcessingResult, error) { + return &verification.AttestationProcessingResult{ + Attestation: &api.Attestation{ + Bundle: data.GitHubReleaseBundle(nil), + BundleURL: "https://example.com", + }, + VerificationResult: nil, + }, nil +} diff --git a/pkg/cmd/release/shared/options.go b/pkg/cmd/release/shared/options.go deleted file mode 100644 index 86e8ac78b..000000000 --- a/pkg/cmd/release/shared/options.go +++ /dev/null @@ -1,76 +0,0 @@ -package shared - -import ( - "fmt" - "net/http" - "path/filepath" - "strings" - - "github.com/cli/cli/v2/internal/gh" - "github.com/cli/cli/v2/internal/ghinstance" - "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/iostreams" -) - -const ReleasePredicateType = "https://in-toto.io/attestation/release/v0.1" - -type AttestOptions struct { - Config func() (gh.Config, error) - HttpClient *http.Client - IO *iostreams.IOStreams - BaseRepo ghrepo.Interface - Exporter cmdutil.Exporter - TagName string - TrustedRoot string - Limit int - Owner string - PredicateType string - Repo string - APIClient api.Client - Logger *io.Handler - SigstoreVerifier verification.SigstoreVerifier - Hostname string - EC verification.EnforcementCriteria - // Tenant is only set when tenancy is used - Tenant string - AssetFilePath string -} - -// Clean cleans the file path option values -func (opts *AttestOptions) Clean() { - if opts.AssetFilePath != "" { - opts.AssetFilePath = filepath.Clean(opts.AssetFilePath) - } -} - -// AreFlagsValid checks that the provided flag combination is valid -// and returns an error otherwise -func (opts *AttestOptions) AreFlagsValid() error { - // If provided, check that the Repo option is in the expected format / - if opts.Repo != "" && !isProvidedRepoValid(opts.Repo) { - return fmt.Errorf("invalid value provided for repo: %s", opts.Repo) - } - - // Check that limit is between 1 and 1000 - if opts.Limit < 1 || opts.Limit > 1000 { - return fmt.Errorf("limit %d not allowed, must be between 1 and 1000", opts.Limit) - } - - if opts.Hostname != "" { - if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { - return fmt.Errorf("error parsing hostname: %w", err) - } - } - - return nil -} - -func isProvidedRepoValid(repo string) bool { - // we expect a provided repository argument be in the format / - splitRepo := strings.Split(repo, "/") - return len(splitRepo) == 2 -} diff --git a/pkg/cmd/release/shared/options_test.go b/pkg/cmd/release/shared/options_test.go deleted file mode 100644 index 7a8fa73dc..000000000 --- a/pkg/cmd/release/shared/options_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package shared - -import ( - "errors" - "testing" -) - -func TestAttestOptions_AreFlagsValid_Valid(t *testing.T) { - opts := &AttestOptions{ - Repo: "owner/repo", - Limit: 10, - } - if err := opts.AreFlagsValid(); err != nil { - t.Errorf("expected no error, got %v", err) - } -} - -func TestAttestOptions_AreFlagsValid_InvalidRepo(t *testing.T) { - opts := &AttestOptions{ - Repo: "invalidrepo", - } - err := opts.AreFlagsValid() - if err == nil || !errors.Is(err, err) { - t.Errorf("expected error for invalid repo, got %v", err) - } -} - -func TestAttestOptions_AreFlagsValid_LimitTooLow(t *testing.T) { - opts := &AttestOptions{ - Repo: "owner/repo", - Limit: 0, - } - err := opts.AreFlagsValid() - if err == nil || !errors.Is(err, err) { - t.Errorf("expected error for limit too low, got %v", err) - } -} - -func TestAttestOptions_AreFlagsValid_LimitTooHigh(t *testing.T) { - opts := &AttestOptions{ - Repo: "owner/repo", - Limit: 1001, - } - err := opts.AreFlagsValid() - if err == nil || !errors.Is(err, err) { - t.Errorf("expected error for limit too high, got %v", err) - } -} - -func TestAttestOptions_AreFlagsValid_ValidHostname(t *testing.T) { - opts := &AttestOptions{ - Repo: "owner/repo", - Limit: 10, - Hostname: "github.com", - } - err := opts.AreFlagsValid() - if err != nil { - t.Errorf("expected no error for valid hostname, got %v", err) - } -} diff --git a/pkg/cmd/release/shared/policy.go b/pkg/cmd/release/shared/policy.go deleted file mode 100644 index 0e3bb322b..000000000 --- a/pkg/cmd/release/shared/policy.go +++ /dev/null @@ -1,76 +0,0 @@ -package shared - -import ( - "fmt" - - "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" - "github.com/sigstore/sigstore-go/pkg/verify" - - "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" -) - -func expandToGitHubURL(tenant, ownerOrRepo string) string { - if tenant == "" { - return fmt.Sprintf("https://github.com/%s", ownerOrRepo) - } - return fmt.Sprintf("https://%s.ghe.com/%s", tenant, ownerOrRepo) -} - -func NewEnforcementCriteria(opts *AttestOptions) (verification.EnforcementCriteria, error) { - // initialize the enforcement criteria with the provided PredicateType and SAN - c := verification.EnforcementCriteria{ - PredicateType: opts.PredicateType, - // TODO: if the proxima is provided, the default uses the proxima-specific SAN - SAN: "https://dotcom.releases.github.com", - } - - // If the Repo option is provided, set the SourceRepositoryURI extension - if opts.Repo != "" { - c.Certificate.SourceRepositoryURI = expandToGitHubURL(opts.Tenant, opts.Repo) - } - - // Set the SourceRepositoryOwnerURI extension using owner and tenant if provided - c.Certificate.SourceRepositoryOwnerURI = expandToGitHubURL(opts.Tenant, opts.Owner) - - return c, nil -} - -func buildCertificateIdentityOption(c verification.EnforcementCriteria) (verify.PolicyOption, error) { - sanMatcher, err := verify.NewSANMatcher(c.SAN, c.SANRegex) - if err != nil { - return nil, err - } - - // Accept any issuer, we will verify the issuer as part of the extension verification - issuerMatcher, err := verify.NewIssuerMatcher("", ".*") - if err != nil { - return nil, err - } - - extensions := certificate.Extensions{ - RunnerEnvironment: c.Certificate.RunnerEnvironment, - } - - certId, err := verify.NewCertificateIdentity(sanMatcher, issuerMatcher, extensions) - if err != nil { - return nil, err - } - - return verify.WithCertificateIdentity(certId), nil -} - -func buildSigstoreVerifyPolicy(c verification.EnforcementCriteria, a artifact.DigestedArtifact) (verify.PolicyBuilder, error) { - artifactDigestPolicyOption, err := verification.BuildDigestPolicyOption(a) - if err != nil { - return verify.PolicyBuilder{}, err - } - - certIdOption, err := buildCertificateIdentityOption(c) - if err != nil { - return verify.PolicyBuilder{}, err - } - - policy := verify.NewPolicy(artifactDigestPolicyOption, certIdOption) - return policy, nil -} diff --git a/pkg/cmd/release/shared/policy_test.go b/pkg/cmd/release/shared/policy_test.go deleted file mode 100644 index 72cc53c2a..000000000 --- a/pkg/cmd/release/shared/policy_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package shared - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestNewEnforcementCriteria(t *testing.T) { - t.Run("check SAN", func(t *testing.T) { - opts := &AttestOptions{ - Owner: "foo", - Repo: "foo/bar", - PredicateType: "https://in-toto.io/attestation/release/v0.1", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://dotcom.releases.github.com", c.SAN) - require.Equal(t, "https://in-toto.io/attestation/release/v0.1", c.PredicateType) - }) - - t.Run("sets Extensions.SourceRepositoryURI using opts.Repo and opts.Tenant", func(t *testing.T) { - opts := &AttestOptions{ - Owner: "foo", - Repo: "foo/bar", - Tenant: "baz", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://baz.ghe.com/foo/bar", c.Certificate.SourceRepositoryURI) - }) - - t.Run("sets Extensions.SourceRepositoryURI using opts.Repo", func(t *testing.T) { - opts := &AttestOptions{ - Owner: "foo", - Repo: "foo/bar", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://github.com/foo/bar", c.Certificate.SourceRepositoryURI) - }) - - t.Run("sets Extensions.SourceRepositoryOwnerURI using opts.Owner and opts.Tenant", func(t *testing.T) { - opts := &AttestOptions{ - - Owner: "foo", - Repo: "foo/bar", - Tenant: "baz", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://baz.ghe.com/foo", c.Certificate.SourceRepositoryOwnerURI) - }) - - t.Run("sets Extensions.SourceRepositoryOwnerURI using opts.Owner", func(t *testing.T) { - opts := &AttestOptions{ - - Owner: "foo", - Repo: "foo/bar", - } - - c, err := NewEnforcementCriteria(opts) - require.NoError(t, err) - require.Equal(t, "https://github.com/foo", c.Certificate.SourceRepositoryOwnerURI) - }) - -} diff --git a/pkg/cmd/release/verify-asset/verify-asset.go b/pkg/cmd/release/verify-asset/verify-asset.go deleted file mode 100644 index 260589d11..000000000 --- a/pkg/cmd/release/verify-asset/verify-asset.go +++ /dev/null @@ -1,219 +0,0 @@ -package verifyasset - -import ( - "context" - "errors" - "fmt" - "path/filepath" - - "github.com/cli/cli/v2/pkg/cmd/attestation/auth" - ghauth "github.com/cli/go-gh/v2/pkg/auth" - - "github.com/cli/cli/v2/internal/text" - "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" - att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmd/release/shared" - - "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/spf13/cobra" -) - -func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *cobra.Command { - opts := &shared.AttestOptions{} - - cmd := &cobra.Command{ - Use: "verify-asset ", - Short: "Verify that a given asset originated from a specific GitHub Release.", - Hidden: true, - Args: cobra.MaximumNArgs(2), - PreRunE: func(cmd *cobra.Command, args []string) error { - - if len(args) == 2 { - opts.TagName = args[0] - opts.AssetFilePath = args[1] - } else if len(args) == 1 { - opts.AssetFilePath = args[0] - } else { - return cmdutil.FlagErrorf("you must specify an asset filepath") - } - - httpClient, err := f.HttpClient() - if err != nil { - return err - } - baseRepo, err := f.BaseRepo() - if err != nil { - return err - } - logger := att_io.NewHandler(f.IOStreams) - hostname, _ := ghauth.DefaultHost() - - err = auth.IsHostSupported(hostname) - if err != nil { - return err - } - - *opts = shared.AttestOptions{ - TagName: opts.TagName, - AssetFilePath: opts.AssetFilePath, - Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), - APIClient: api.NewLiveClient(httpClient, hostname, logger), - Limit: 10, - Owner: baseRepo.RepoOwner(), - PredicateType: shared.ReleasePredicateType, - Logger: logger, - HttpClient: httpClient, - BaseRepo: baseRepo, - Hostname: hostname, - } - - // Check that the given flag combination is valid - if err := opts.AreFlagsValid(); err != nil { - return err - } - - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - td, err := opts.APIClient.GetTrustDomain() - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to get trust domain")) - return err - } - - opts.TrustedRoot = td - - ec, err := shared.NewEnforcementCriteria(opts) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to build policy information")) - return err - } - - opts.EC = ec - - opts.Clean() - - // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes - if runF != nil { - return runF(opts) - } - - return verifyAssetRun(opts) - }, - } - cmdutil.AddFormatFlags(cmd, &opts.Exporter) - - return cmd -} - -func verifyAssetRun(opts *shared.AttestOptions) error { - ctx := context.Background() - - if opts.SigstoreVerifier == nil { - config := verification.SigstoreConfig{ - HttpClient: opts.HttpClient, - Logger: opts.Logger, - NoPublicGood: true, - TrustDomain: opts.TrustedRoot, - } - - sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to create Sigstore verifier")) - return err - } - - opts.SigstoreVerifier = sigstoreVerifier - } - - if opts.TagName == "" { - release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) - if err != nil { - return err - } - opts.TagName = release.TagName - } - - fileName := getFileName(opts.AssetFilePath) - - // calculate the digest of the file - fileDigest, err := artifact.NewDigestedArtifact(nil, opts.AssetFilePath, "sha256") - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to calculate file digest")) - return err - } - - opts.Logger.Printf("Loaded digest %s for %s\n", fileDigest.DigestWithAlg(), fileName) - - ref, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) - if err != nil { - return err - } - releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") - opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) - - // Attestation fetching - attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) - if err != nil { - if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) - return err - } - opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) - return err - } - - // Filter attestations by tag - filteredAttestations, err := shared.FilterAttestationsByTag(attestations, opts.TagName) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) - return err - } - - filteredAttestations, err = shared.FilterAttestationsByFileDigest(filteredAttestations, opts.Repo, opts.TagName, fileDigest.Digest()) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) - return err - } - - if len(filteredAttestations) == 0 { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) - return fmt.Errorf("release %s does not contain %s (%s)", opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) - } - - opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) - - // Verify attestations - verified, errMsg, err := shared.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) - - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("Release %s does not contain %s (%s)\n"), opts.TagName, opts.AssetFilePath, fileDigest.DigestWithAlg()) - return err - } - - // If an exporter is provided with the --json flag, write the results to the terminal in JSON format - if opts.Exporter != nil { - // print the results to the terminal as an array of JSON objects - if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to write JSON output")) - return err - } - return nil - } - - opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) - opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) - opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseRefDigest.DigestWithAlg()) - opts.Logger.Printf("%s is present in release %s\n", fileName, opts.TagName) - - return nil -} - -func getFileName(filePath string) string { - // Get the file name from the file path - _, fileName := filepath.Split(filePath) - return fileName -} diff --git a/pkg/cmd/release/verify-asset/verify-asset_test.go b/pkg/cmd/release/verify-asset/verify-asset_test.go deleted file mode 100644 index a85c9066e..000000000 --- a/pkg/cmd/release/verify-asset/verify-asset_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package verifyasset - -import ( - "bytes" - "net/http" - "testing" - - "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/io" - "github.com/cli/cli/v2/pkg/cmd/attestation/test" - "github.com/cli/cli/v2/pkg/cmd/attestation/verification" - "github.com/cli/cli/v2/pkg/cmd/release/shared" - "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/iostreams" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/cli/cli/v2/internal/ghrepo" - - attestation "github.com/cli/cli/v2/pkg/cmd/release/shared" - "github.com/cli/cli/v2/pkg/httpmock" -) - -func TestNewCmdVerifyAsset_Args(t *testing.T) { - tests := []struct { - name string - args []string - wantTag string - wantFile string - wantErr string - }{ - { - name: "valid args", - args: []string{"v1.2.3", "../../attestation/test/data/github_release_artifact.zip"}, - wantTag: "v1.2.3", - wantFile: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), - }, - { - name: "valid flag with no tag", - - args: []string{"../../attestation/test/data/github_release_artifact.zip"}, - wantFile: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), - }, - { - name: "no args", - args: []string{}, - wantErr: "you must specify an asset filepath", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - testIO, _, _, _ := iostreams.Test() - var testReg httpmock.Registry - var metaResp = api.MetaResponse{ - Domains: api.Domain{ - ArtifactAttestations: api.ArtifactAttestations{}, - }, - } - testReg.Register(httpmock.REST(http.MethodGet, "meta"), - httpmock.StatusJSONResponse(200, &metaResp)) - - f := &cmdutil.Factory{ - IOStreams: testIO, - HttpClient: func() (*http.Client, error) { - reg := &testReg - client := &http.Client{} - httpmock.ReplaceTripper(client, reg) - return client, nil - }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.FromFullName("owner/repo") - }, - } - - var opts *shared.AttestOptions - cmd := NewCmdVerifyAsset(f, func(o *shared.AttestOptions) error { - opts = o - return nil - }) - cmd.SetArgs(tt.args) - cmd.SetIn(&bytes.Buffer{}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - _, err := cmd.ExecuteC() - if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) - } else { - require.NoError(t, err) - assert.Equal(t, tt.wantTag, opts.TagName) - assert.Equal(t, tt.wantFile, opts.AssetFilePath) - } - }) - } -} - -func Test_verifyAssetRun_Success(t *testing.T) { - ios, _, _, _ := iostreams.Test() - tagName := "v6" - - fakeHTTP := &httpmock.Registry{} - defer fakeHTTP.Verify(t) - fakeSHA := "1234567890abcdef1234567890abcdef12345678" - shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) - - baseRepo, err := ghrepo.FromFullName("owner/repo") - require.NoError(t, err) - - opts := &shared.AttestOptions{ - TagName: tagName, - AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: shared.ReleasePredicateType, - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - } - - ec, err := shared.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec - opts.Clean() - err = verifyAssetRun(opts) - require.NoError(t, err) -} - -func Test_verifyAssetRun_Failed_With_Invalid_tag(t *testing.T) { - ios, _, _, _ := iostreams.Test() - tagName := "v1" - - fakeHTTP := &httpmock.Registry{} - defer fakeHTTP.Verify(t) - fakeSHA := "1234567890abcdef1234567890abcdef12345678" - shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) - - baseRepo, err := ghrepo.FromFullName("owner/repo") - require.NoError(t, err) - - opts := &attestation.AttestOptions{ - TagName: tagName, - AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: attestation.ReleasePredicateType, - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - } - - ec, err := attestation.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec - - err = verifyAssetRun(opts) - require.Error(t, err, "no attestations found for github_release_artifact.zip in release v1") -} - -func Test_verifyAssetRun_Failed_With_Invalid_Artifact(t *testing.T) { - ios, _, _, _ := iostreams.Test() - tagName := "v6" - - fakeHTTP := &httpmock.Registry{} - defer fakeHTTP.Verify(t) - fakeSHA := "1234567890abcdef1234567890abcdef12345678" - shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) - - baseRepo, err := ghrepo.FromFullName("owner/repo") - require.NoError(t, err) - - opts := &attestation.AttestOptions{ - TagName: tagName, - AssetFilePath: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: attestation.ReleasePredicateType, - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - } - - err = verifyAssetRun(opts) - require.Error(t, err, "no attestations found for github_release_artifact_invalid.zip in release v1.2.3") -} - -func Test_verifyAssetRun_NoAttestation(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &attestation.AttestOptions{ - TagName: "v1.2.3", - AssetFilePath: "artifact.tgz", - Repo: "owner/repo", - Limit: 10, - Logger: io.NewHandler(ios), - IO: ios, - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: attestation.ReleasePredicateType, - - EC: verification.EnforcementCriteria{}, - } - - err := verifyAssetRun(opts) - require.Error(t, err, "failed to get open local artifact: open artifact.tgz: no such file or director") -} - -func Test_getFileName(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"foo/bar/baz.txt", "baz.txt"}, - {"baz.txt", "baz.txt"}, - {"/tmp/foo.tar.gz", "foo.tar.gz"}, - } - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := getFileName(tt.input) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/pkg/cmd/release/verify-asset/verify_asset.go b/pkg/cmd/release/verify-asset/verify_asset.go new file mode 100644 index 000000000..ddafbb265 --- /dev/null +++ b/pkg/cmd/release/verify-asset/verify_asset.go @@ -0,0 +1,182 @@ +package verifyasset + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + + "github.com/cli/cli/v2/pkg/iostreams" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" + att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/release/shared" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +type VerifyAssetOptions struct { + TagName string + BaseRepo ghrepo.Interface + Exporter cmdutil.Exporter + AssetFilePath string +} + +type VerifyAssetConfig struct { + HttpClient *http.Client + IO *iostreams.IOStreams + Opts *VerifyAssetOptions + AttClient api.Client + AttVerifier shared.Verifier +} + +func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*VerifyAssetConfig) error) *cobra.Command { + opts := &VerifyAssetOptions{} + + cmd := &cobra.Command{ + Use: "verify-asset ", + Short: "Verify that a given asset originated from a specific GitHub Release.", + Hidden: true, + Args: cobra.MaximumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 2 { + opts.TagName = args[0] + opts.AssetFilePath = args[1] + } else if len(args) == 1 { + opts.AssetFilePath = args[0] + } else { + return cmdutil.FlagErrorf("you must specify an asset filepath") + } + + opts.AssetFilePath = filepath.Clean(opts.AssetFilePath) + + baseRepo, err := f.BaseRepo() + if err != nil { + return fmt.Errorf("failed to determine base repository: %w", err) + } + opts.BaseRepo = baseRepo + + httpClient, err := f.HttpClient() + if err != nil { + return err + } + + io := f.IOStreams + attClient := api.NewLiveClient(httpClient, baseRepo.RepoHost(), att_io.NewHandler(io)) + + attVerifier := &shared.AttestationVerifier{ + AttClient: attClient, + HttpClient: httpClient, + IO: io, + } + + config := &VerifyAssetConfig{ + Opts: opts, + HttpClient: httpClient, + AttClient: attClient, + AttVerifier: attVerifier, + IO: io, + } + + if runF != nil { + return runF(config) + } + + return verifyAssetRun(config) + }, + } + cmdutil.AddFormatFlags(cmd, &opts.Exporter) + + return cmd +} + +func verifyAssetRun(config *VerifyAssetConfig) error { + ctx := context.Background() + opts := config.Opts + baseRepo := opts.BaseRepo + tagName := opts.TagName + + if tagName == "" { + release, err := shared.FetchLatestRelease(ctx, config.HttpClient, baseRepo) + if err != nil { + return err + } + tagName = release.TagName + } + + fileName := getFileName(opts.AssetFilePath) + + // Calculate the digest of the file + fileDigest, err := artifact.NewDigestedArtifact(nil, opts.AssetFilePath, "sha256") + if err != nil { + return err + } + + ref, err := shared.FetchRefSHA(ctx, config.HttpClient, baseRepo, tagName) + if err != nil { + return err + } + + releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") + + // Find attestaitons for the release tag SHA + attestations, err := config.AttClient.GetByDigest(api.FetchParams{ + Digest: releaseRefDigest.DigestWithAlg(), + PredicateType: shared.ReleasePredicateType, + Owner: baseRepo.RepoOwner(), + Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), + Limit: 10, + }) + if err != nil { + return fmt.Errorf("no attestations found for tag %s (%s)", tagName, releaseRefDigest.DigestWithAlg()) + } + + // Filter attestations by tag name + filteredAttestations, err := shared.FilterAttestationsByTag(attestations, opts.TagName) + if err != nil { + return fmt.Errorf("error parsing attestations for tag %s: %w", tagName, err) + } + + if len(filteredAttestations) == 0 { + return fmt.Errorf("no attestations found for release %s in %s/%s", tagName, baseRepo.RepoOwner(), baseRepo.RepoName()) + } + + // Filter attestations by subject digest + filteredAttestations, err = shared.FilterAttestationsByFileDigest(filteredAttestations, fileDigest.Digest()) + if err != nil { + return fmt.Errorf("error parsing attestations for digest %s: %w", fileDigest.DigestWithAlg(), err) + } + + if len(filteredAttestations) == 0 { + return fmt.Errorf("attestation for %s does not contain subject %s", tagName, fileDigest.DigestWithAlg()) + } + + // Verify attestation + verified, err := config.AttVerifier.VerifyAttestation(releaseRefDigest, filteredAttestations[0]) + if err != nil { + return fmt.Errorf("failed to verify attestation for tag %s: %w", tagName, err) + } + + // If an exporter is provided with the --json flag, write the results to the terminal in JSON format + if opts.Exporter != nil { + return opts.Exporter.Write(config.IO, verified) + } + + io := config.IO + cs := io.ColorScheme() + fmt.Fprintf(io.Out, "Calculated digest for %s: %s\n", fileName, fileDigest.DigestWithAlg()) + fmt.Fprintf(io.Out, "Resolved tag %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) + fmt.Fprint(io.Out, "Loaded attestation from GitHub API\n\n") + fmt.Fprintf(io.Out, cs.Green("%s Verification succeeded! %s is present in release %s\n"), cs.SuccessIcon(), fileName, opts.TagName) + + return nil +} + +func getFileName(filePath string) string { + // Get the file name from the file path + _, fileName := filepath.Split(filePath) + return fileName +} diff --git a/pkg/cmd/release/verify-asset/verify_asset_test.go b/pkg/cmd/release/verify-asset/verify_asset_test.go new file mode 100644 index 000000000..732de9fd2 --- /dev/null +++ b/pkg/cmd/release/verify-asset/verify_asset_test.go @@ -0,0 +1,267 @@ +package verifyasset + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/v2/pkg/cmd/attestation/api" + "github.com/cli/cli/v2/pkg/cmd/attestation/test" + "github.com/cli/cli/v2/pkg/cmd/attestation/test/data" + "github.com/cli/cli/v2/pkg/cmd/attestation/verification" + "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/cli/cli/v2/internal/ghrepo" +) + +func TestNewCmdVerifyAsset_Args(t *testing.T) { + tests := []struct { + name string + args []string + wantTag string + wantFile string + wantErr string + }{ + { + name: "valid args", + args: []string{"v1.2.3", "../../attestation/test/data/github_release_artifact.zip"}, + wantTag: "v1.2.3", + wantFile: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), + }, + { + name: "valid flag with no tag", + + args: []string{"../../attestation/test/data/github_release_artifact.zip"}, + wantFile: test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip"), + }, + { + name: "no args", + args: []string{}, + wantErr: "you must specify an asset filepath", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testIO, _, _, _ := iostreams.Test() + + f := &cmdutil.Factory{ + IOStreams: testIO, + HttpClient: func() (*http.Client, error) { + return nil, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + } + + var cfg *VerifyAssetConfig + cmd := NewCmdVerifyAsset(f, func(c *VerifyAssetConfig) error { + cfg = c + return nil + }) + cmd.SetArgs(tt.args) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err := cmd.ExecuteC() + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantTag, cfg.Opts.TagName) + assert.Equal(t, tt.wantFile, cfg.Opts.AssetFilePath) + } + }) + } +} + +func Test_verifyAssetRun_Success(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v6" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + result := &verification.AttestationProcessingResult{ + Attestation: &api.Attestation{ + Bundle: data.GitHubReleaseBundle(t), + BundleURL: "https://example.com", + }, + VerificationResult: nil, + } + + releaseAssetPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") + + cfg := &VerifyAssetConfig{ + Opts: &VerifyAssetOptions{ + AssetFilePath: releaseAssetPath, + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewTestClient(), + AttVerifier: shared.NewMockVerifier(result), + } + + err = verifyAssetRun(cfg) + require.NoError(t, err) +} + +func Test_verifyAssetRun_FailedNoAttestations(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + releaseAssetPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") + + cfg := &VerifyAssetConfig{ + Opts: &VerifyAssetOptions{ + AssetFilePath: releaseAssetPath, + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewFailTestClient(), + AttVerifier: nil, + } + + err = verifyAssetRun(cfg) + require.ErrorContains(t, err, "no attestations found for tag v1") +} + +func Test_verifyAssetRun_FailedTagNotInAttestation(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + // Tag name does not match the one present in the attestation which + // will be returned by the mock client. Simulates a scenario where + // multiple releases may point to the same commit SHA, but not all + // of them are attested. + tagName := "v1.2.3" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + releaseAssetPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") + + cfg := &VerifyAssetConfig{ + Opts: &VerifyAssetOptions{ + AssetFilePath: releaseAssetPath, + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewTestClient(), + AttVerifier: nil, + } + + err = verifyAssetRun(cfg) + require.ErrorContains(t, err, "no attestations found for release v1.2.3") +} + +func Test_verifyAssetRun_FailedInvalidAsset(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v6" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + releaseAssetPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact_invalid.zip") + + cfg := &VerifyAssetConfig{ + Opts: &VerifyAssetOptions{ + AssetFilePath: releaseAssetPath, + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewTestClient(), + AttVerifier: nil, + } + + err = verifyAssetRun(cfg) + require.ErrorContains(t, err, "attestation for v6 does not contain subject") +} + +func Test_verifyAssetRun_NoSuchAsset(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v6" + + fakeHTTP := &httpmock.Registry{} + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + cfg := &VerifyAssetConfig{ + Opts: &VerifyAssetOptions{ + AssetFilePath: "artifact.zip", + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewTestClient(), + AttVerifier: nil, + } + + err = verifyAssetRun(cfg) + require.ErrorContains(t, err, "failed to open local artifact") +} + +func Test_getFileName(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"foo/bar/baz.txt", "baz.txt"}, + {"baz.txt", "baz.txt"}, + {"/tmp/foo.tar.gz", "foo.tar.gz"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := getFileName(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index b8276f989..95708fa5a 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -2,94 +2,86 @@ package verify import ( "context" - "errors" "fmt" + "net/http" v1 "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/encoding/protojson" - "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" - "github.com/cli/cli/v2/pkg/cmd/attestation/auth" att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/cmdutil" - ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/spf13/cobra" ) -func NewCmdVerify(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *cobra.Command { - opts := &shared.AttestOptions{} +type VerifyOptions struct { + TagName string + BaseRepo ghrepo.Interface + Exporter cmdutil.Exporter +} + +type VerifyConfig struct { + HttpClient *http.Client + IO *iostreams.IOStreams + Opts *VerifyOptions + AttClient api.Client + AttVerifier shared.Verifier +} + +func NewCmdVerify(f *cmdutil.Factory, runF func(config *VerifyConfig) error) *cobra.Command { + opts := &VerifyOptions{} cmd := &cobra.Command{ Use: "verify []", Short: "Verify the attestation for a GitHub Release.", Hidden: true, Args: cobra.MaximumNArgs(1), - PreRunE: func(cmd *cobra.Command, args []string) error { + + RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { opts.TagName = args[0] } + baseRepo, err := f.BaseRepo() + if err != nil { + return fmt.Errorf("failed to determine base repository: %w", err) + } + + opts.BaseRepo = baseRepo + httpClient, err := f.HttpClient() if err != nil { return err } - baseRepo, err := f.BaseRepo() - if err != nil { - return err - } - logger := att_io.NewHandler(f.IOStreams) - hostname, _ := ghauth.DefaultHost() + io := f.IOStreams + attClient := api.NewLiveClient(httpClient, baseRepo.RepoHost(), att_io.NewHandler(io)) - err = auth.IsHostSupported(hostname) - if err != nil { - return err + attVerifier := &shared.AttestationVerifier{ + AttClient: attClient, + HttpClient: httpClient, + IO: io, } - *opts = shared.AttestOptions{ - TagName: opts.TagName, - Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), - APIClient: api.NewLiveClient(httpClient, hostname, logger), - Limit: 10, - Owner: baseRepo.RepoOwner(), - PredicateType: shared.ReleasePredicateType, - Logger: logger, - HttpClient: httpClient, - BaseRepo: baseRepo, - Hostname: hostname, + config := &VerifyConfig{ + Opts: opts, + HttpClient: httpClient, + AttClient: attClient, + AttVerifier: attVerifier, + IO: io, } - // Check that the given flag combination is valid - if err := opts.AreFlagsValid(); err != nil { - return err - } - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - td, err := opts.APIClient.GetTrustDomain() - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to get trust domain")) - return err - } - opts.TrustedRoot = td - - ec, err := shared.NewEnforcementCriteria(opts) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to build policy information")) - return err - } - opts.EC = ec - - // Avoid creating a Sigstore verifier if the runF function is provided for testing purposes if runF != nil { - return runF(opts) + return runF(config) } - return verifyRun(opts) + return verifyRun(config) }, } cmdutil.AddFormatFlags(cmd, &opts.Exporter) @@ -97,115 +89,119 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(*shared.AttestOptions) error) *c return cmd } -func verifyRun(opts *shared.AttestOptions) error { +func verifyRun(config *VerifyConfig) error { ctx := context.Background() + opts := config.Opts + baseRepo := opts.BaseRepo + tagName := opts.TagName - if opts.SigstoreVerifier == nil { - config := verification.SigstoreConfig{ - HttpClient: opts.HttpClient, - Logger: opts.Logger, - NoPublicGood: true, - TrustDomain: opts.TrustedRoot, - } - - sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config) - if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to create Sigstore verifier")) - return err - } - - opts.SigstoreVerifier = sigstoreVerifier - } - - if opts.TagName == "" { - release, err := shared.FetchLatestRelease(ctx, opts.HttpClient, opts.BaseRepo) + if tagName == "" { + release, err := shared.FetchLatestRelease(ctx, config.HttpClient, baseRepo) if err != nil { return err } - opts.TagName = release.TagName + tagName = release.TagName } - ref, err := shared.FetchRefSHA(ctx, opts.HttpClient, opts.BaseRepo, opts.TagName) + // Retrieve the ref for the release tag + ref, err := shared.FetchRefSHA(ctx, config.HttpClient, baseRepo, tagName) if err != nil { return err } releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") - opts.Logger.Printf("Resolved %s to %s\n", opts.TagName, releaseRefDigest.DigestWithAlg()) - // Attestation fetching - attestations, logMsg, err := shared.GetAttestations(opts, releaseRefDigest.DigestWithAlg()) + // Find attestaitons for the release tag SHA + attestations, err := config.AttClient.GetByDigest(api.FetchParams{ + Digest: releaseRefDigest.DigestWithAlg(), + PredicateType: shared.ReleasePredicateType, + Owner: baseRepo.RepoOwner(), + Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), + Limit: 10, + }) if err != nil { - if errors.Is(err, api.ErrNoAttestationsFound) { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for subject %s\n"), releaseRefDigest.DigestWithAlg()) - return err - } - opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg)) - return err + return fmt.Errorf("no attestations for tag %s (%s)", tagName, releaseRefDigest.DigestWithAlg()) } - // Filter attestations by predicate tag - filteredAttestations, err := shared.FilterAttestationsByTag(attestations, opts.TagName) + // Filter attestations by tag name + filteredAttestations, err := shared.FilterAttestationsByTag(attestations, tagName) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(err.Error())) - return err + return fmt.Errorf("error parsing attestations for tag %s: %w", tagName, err) } if len(filteredAttestations) == 0 { - opts.Logger.Printf(opts.Logger.ColorScheme.Red("X No attestations found for release %s in %s\n"), opts.TagName, opts.Repo) - return fmt.Errorf("no attestations found for release %s in %s", opts.TagName, opts.Repo) + return fmt.Errorf("no attestations found for release %s in %s", tagName, baseRepo.RepoName()) } - opts.Logger.Printf("Loaded %s from GitHub API\n", text.Pluralize(len(filteredAttestations), "attestation")) - - // Verify attestations - verified, errMsg, err := shared.VerifyAttestations(*releaseRefDigest, filteredAttestations, opts.SigstoreVerifier, opts.EC) + if len(filteredAttestations) > 1 { + return fmt.Errorf("duplicate attestations found for release %s in %s", tagName, baseRepo.RepoName()) + } + // Verify attestation + verified, err := config.AttVerifier.VerifyAttestation(releaseRefDigest, filteredAttestations[0]) if err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg)) - opts.Logger.Printf(opts.Logger.ColorScheme.Red("X Failed to find an attestation for release %s in %s\n"), opts.TagName, opts.Repo) - return err + return fmt.Errorf("failed to verify attestations for tag %s: %w", tagName, err) } // If an exporter is provided with the --json flag, write the results to the terminal in JSON format if opts.Exporter != nil { - // print the results to the terminal as an array of JSON objects - if err = opts.Exporter.Write(opts.Logger.IO, verified); err != nil { - opts.Logger.Println(opts.Logger.ColorScheme.Red("X Failed to write JSON output")) - return err - } - return nil + return opts.Exporter.Write(config.IO, verified) } - opts.Logger.Printf("The following %s matched the policy criteria\n\n", text.Pluralize(len(verified), "attestation")) - opts.Logger.Println(opts.Logger.ColorScheme.Green("✓ Verification succeeded!\n")) + io := config.IO + cs := io.ColorScheme() + fmt.Fprintf(io.Out, "Resolved tag %s to %s\n", tagName, releaseRefDigest.DigestWithAlg()) + fmt.Fprint(io.Out, "Loaded attestation from GitHub API\n") + fmt.Fprintf(io.Out, cs.Green("%s Release %s verified!\n"), cs.SuccessIcon(), tagName) + fmt.Fprintln(io.Out) - opts.Logger.Printf("Attestation found matching release %s (%s)\n", opts.TagName, releaseRefDigest.Digest()) - printVerifiedSubjects(verified, opts.Logger) + if err := printVerifiedSubjects(io, verified); err != nil { + return err + } return nil } -func printVerifiedSubjects(verified []*verification.AttestationProcessingResult, logger *att_io.Handler) { - for _, att := range verified { - statement := att.Attestation.Bundle.GetDsseEnvelope().Payload - var statementData v1.Statement - err := protojson.Unmarshal([]byte(statement), &statementData) - if err != nil { - logger.Println(logger.ColorScheme.Red("X Failed to unmarshal statement")) - continue - } - for _, s := range statementData.Subject { - name := s.Name - digest := s.Digest +func printVerifiedSubjects(io *iostreams.IOStreams, att *verification.AttestationProcessingResult) error { + cs := io.ColorScheme() + w := io.Out - if name != "" { - digestStr := "" - for key, value := range digest { - digestStr += key + ":" + value - } - logger.Println(" " + name + " " + digestStr) + statement := att.Attestation.Bundle.GetDsseEnvelope().Payload + var statementData v1.Statement + + err := protojson.Unmarshal([]byte(statement), &statementData) + if err != nil { + return err + } + + // If there aren't at least two subjects, there are no assets to display + if len(statementData.Subject) < 2 { + return nil + } + + fmt.Fprintln(w, cs.Bold("Assets")) + table := tableprinter.New(io, tableprinter.WithHeader("Name", "Digest")) + + for _, s := range statementData.Subject { + name := s.Name + digest := s.Digest + + if name != "" { + digestStr := "" + for key, value := range digest { + digestStr = key + ":" + value } + + table.AddField(name) + table.AddField(digestStr) + table.EndRow() } } + err = table.Render() + if err != nil { + return err + } + fmt.Fprintln(w) + + return nil } diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index b0a1c7df5..40009fc7d 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -7,7 +7,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/attestation/api" - "github.com/cli/cli/v2/pkg/cmd/attestation/io" + "github.com/cli/cli/v2/pkg/cmd/attestation/test/data" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -38,40 +38,30 @@ func TestNewCmdVerify_Args(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { testIO, _, _, _ := iostreams.Test() - var testReg httpmock.Registry - var metaResp = api.MetaResponse{ - Domains: api.Domain{ - ArtifactAttestations: api.ArtifactAttestations{}, - }, - } - testReg.Register(httpmock.REST(http.MethodGet, "meta"), - httpmock.StatusJSONResponse(200, &metaResp)) - f := &cmdutil.Factory{ IOStreams: testIO, HttpClient: func() (*http.Client, error) { - reg := &testReg - client := &http.Client{} - httpmock.ReplaceTripper(client, reg) - return client, nil + return nil, nil }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, } - var opts *shared.AttestOptions - cmd := NewCmdVerify(f, func(o *shared.AttestOptions) error { - opts = o + var cfg *VerifyConfig + cmd := NewCmdVerify(f, func(c *VerifyConfig) error { + cfg = c return nil }) cmd.SetArgs(tt.args) cmd.SetIn(&bytes.Buffer{}) cmd.SetOut(&bytes.Buffer{}) cmd.SetErr(&bytes.Buffer{}) + _, err := cmd.ExecuteC() + require.NoError(t, err) - assert.Equal(t, tt.wantTag, opts.TagName) + assert.Equal(t, tt.wantTag, cfg.Opts.TagName) }) } } @@ -82,35 +72,72 @@ func Test_verifyRun_Success(t *testing.T) { fakeHTTP := &httpmock.Registry{} defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - opts := &shared.AttestOptions{ - TagName: tagName, - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - PredicateType: shared.ReleasePredicateType, + result := &verification.AttestationProcessingResult{ + Attestation: &api.Attestation{ + Bundle: data.GitHubReleaseBundle(t), + BundleURL: "https://example.com", + }, + VerificationResult: nil, } - ec, err := shared.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec + cfg := &VerifyConfig{ + Opts: &VerifyOptions{ + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewTestClient(), + AttVerifier: shared.NewMockVerifier(result), + } - err = verifyRun(opts) + err = verifyRun(cfg) require.NoError(t, err) } -func Test_verifyRun_Failed_With_Invalid_Tag(t *testing.T) { +func Test_verifyRun_FailedNoAttestations(t *testing.T) { ios, _, _, _ := iostreams.Test() + tagName := "v1" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef12345678" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + cfg := &VerifyConfig{ + Opts: &VerifyOptions{ + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewFailTestClient(), + AttVerifier: nil, + } + + err = verifyRun(cfg) + require.ErrorContains(t, err, "no attestations for tag v1") +} + +func Test_verifyRun_FailedTagNotInAttestation(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + // Tag name does not match the one present in the attestation which + // will be returned by the mock client. Simulates a scenario where + // multiple releases may point to the same commit SHA, but not all + // of them are attested. tagName := "v1.2.3" fakeHTTP := &httpmock.Registry{} @@ -121,57 +148,18 @@ func Test_verifyRun_Failed_With_Invalid_Tag(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) - opts := &shared.AttestOptions{ - TagName: tagName, - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewFailTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - PredicateType: shared.ReleasePredicateType, - - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, + cfg := &VerifyConfig{ + Opts: &VerifyOptions{ + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewTestClient(), + AttVerifier: nil, } - ec, err := shared.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec - - err = verifyRun(opts) - require.Error(t, err, "failed to fetch attestations from owner/repo") -} - -func Test_verifyRun_Failed_NoAttestation(t *testing.T) { - ios, _, _, _ := iostreams.Test() - tagName := "v1.2.3" - - fakeHTTP := &httpmock.Registry{} - defer fakeHTTP.Verify(t) - fakeSHA := "1234567890abcdef1234567890abcdef12345678" - shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) - - baseRepo, err := ghrepo.FromFullName("owner/repo") - require.NoError(t, err) - - opts := &shared.AttestOptions{ - TagName: tagName, - Repo: "owner/repo", - Owner: "owner", - Limit: 10, - Logger: io.NewHandler(ios), - APIClient: api.NewFailTestClient(), - SigstoreVerifier: verification.NewMockSigstoreVerifier(t), - HttpClient: &http.Client{Transport: fakeHTTP}, - BaseRepo: baseRepo, - PredicateType: shared.ReleasePredicateType, - } - - ec, err := shared.NewEnforcementCriteria(opts) - require.NoError(t, err) - opts.EC = ec - - err = verifyRun(opts) - require.Error(t, err, "failed to fetch attestations from owner/repo") + err = verifyRun(cfg) + require.ErrorContains(t, err, "no attestations found for release v1.2.3") } From bde303dab28caac33926e38d5de6591a0b4c9a48 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 10 Jun 2025 13:57:31 +0100 Subject: [PATCH 224/249] test: fix test data const Signed-off-by: Babak K. Shandiz --- pkg/cmd/pr/shared/finder_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index 28c2337d3..0fd96e09b 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -728,7 +728,7 @@ func TestFindAssignableActors(t *testing.T) { pr, _, err := f.Find(FindOptions{ Detector: &fd.DisabledDetectorMock{}, Fields: []string{"assignees"}, - Selector: "https://github.com/cli/cli/pull/123", + Selector: "https://github.com/cli/cli/pull/13", }) require.NoError(t, err) @@ -772,7 +772,7 @@ func TestFindAssignableActors(t *testing.T) { pr, _, err := f.Find(FindOptions{ Detector: &fd.EnabledDetectorMock{}, Fields: []string{"assignees"}, - Selector: "https://github.com/cli/cli/pull/123", + Selector: "https://github.com/cli/cli/pull/13", }) require.NoError(t, err) From 52bde8a3f7b1218cb11f6369671623ac0af81b48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 14:52:22 +0000 Subject: [PATCH 225/249] chore(deps): bump github.com/sigstore/protobuf-specs from 0.4.2 to 0.4.3 Bumps [github.com/sigstore/protobuf-specs](https://github.com/sigstore/protobuf-specs) from 0.4.2 to 0.4.3. - [Release notes](https://github.com/sigstore/protobuf-specs/releases) - [Changelog](https://github.com/sigstore/protobuf-specs/blob/main/CHANGELOG.md) - [Commits](https://github.com/sigstore/protobuf-specs/compare/v0.4.2...v0.4.3) --- updated-dependencies: - dependency-name: github.com/sigstore/protobuf-specs dependency-version: 0.4.3 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 a4c973df1..49b7c8ddf 100644 --- a/go.mod +++ b/go.mod @@ -44,7 +44,7 @@ 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.4.2 + github.com/sigstore/protobuf-specs v0.4.3 github.com/sigstore/sigstore-go v1.0.0 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 diff --git a/go.sum b/go.sum index 718e0ca67..598a713a2 100644 --- a/go.sum +++ b/go.sum @@ -455,8 +455,8 @@ 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.4.2 h1:bD5bnhctpGNiR+FAEZl7N95XkN8TJFrNMIcWLunDtxA= -github.com/sigstore/protobuf-specs v0.4.2/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= +github.com/sigstore/protobuf-specs v0.4.3 h1:kRgJ+ciznipH9xhrkAbAEHuuxD3GhYnGC873gZpjJT4= +github.com/sigstore/protobuf-specs v0.4.3/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= github.com/sigstore/rekor v1.3.10 h1:/mSvRo4MZ/59ECIlARhyykAlQlkmeAQpvBPlmJtZOCU= github.com/sigstore/rekor v1.3.10/go.mod h1:JvryKJ40O0XA48MdzYUPu0y4fyvqt0C4iSY7ri9iu3A= github.com/sigstore/sigstore v1.9.4 h1:64+OGed80+A4mRlNzRd055vFcgBeDghjZw24rPLZgDU= From ee3db50e43915adc8dd1c3014a0ac5f491f7e50b Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 11 Jun 2025 13:47:18 +0200 Subject: [PATCH 226/249] Avoid requesting PR reviewer twice --- api/queries_repo.go | 23 +++++++------ api/queries_repo_test.go | 72 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index efbcfcb19..3190745ea 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -709,19 +709,22 @@ func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { } // Look for ID in assignable actors if not found in assignable users - for _, a := range m.AssignableActors { - if strings.EqualFold(assigneeLogin, a.Login()) { - ids = append(ids, a.ID()) - found = true - break - } - if strings.EqualFold(assigneeLogin, a.DisplayName()) { - ids = append(ids, a.ID()) - found = true - break + if !found { + for _, a := range m.AssignableActors { + if strings.EqualFold(assigneeLogin, a.Login()) { + ids = append(ids, a.ID()) + found = true + break + } + if strings.EqualFold(assigneeLogin, a.DisplayName()) { + ids = append(ids, a.ID()) + found = true + break + } } } + // And if we still didn't find an ID, return an error if !found { return nil, fmt.Errorf("'%s' not found", assigneeLogin) } diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 9040a0018..c291fc468 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -461,6 +461,78 @@ t001: team(slug:"robots"){id,slug} } } +func TestMembersToIDs(t *testing.T) { + t.Parallel() + + t.Run("finds ids in assignable users", func(t *testing.T) { + t.Parallel() + + repoMetadataResult := RepoMetadataResult{ + AssignableUsers: []AssignableUser{ + NewAssignableUser("MONAID", "monalisa", ""), + NewAssignableUser("MONAID2", "monalisa2", ""), + }, + AssignableActors: []AssignableActor{ + NewAssignableBot("HUBOTID", "hubot"), + }, + } + ids, err := repoMetadataResult.MembersToIDs([]string{"monalisa"}) + require.NoError(t, err) + require.Equal(t, []string{"MONAID"}, ids) + }) + + t.Run("finds ids by assignable actor logins", func(t *testing.T) { + t.Parallel() + + repoMetadataResult := RepoMetadataResult{ + AssignableActors: []AssignableActor{ + NewAssignableBot("HUBOTID", "hubot"), + NewAssignableUser("MONAID", "monalisa", ""), + }, + } + ids, err := repoMetadataResult.MembersToIDs([]string{"monalisa"}) + require.NoError(t, err) + require.Equal(t, []string{"MONAID"}, ids) + }) + + t.Run("finds ids by assignable actor display names", func(t *testing.T) { + t.Parallel() + + repoMetadataResult := RepoMetadataResult{ + AssignableActors: []AssignableActor{ + NewAssignableUser("MONAID", "monalisa", "mona"), + }, + } + ids, err := repoMetadataResult.MembersToIDs([]string{"monalisa (mona)"}) + require.NoError(t, err) + require.Equal(t, []string{"MONAID"}, ids) + }) + + t.Run("when a name appears in both assignable users and actors, the id is only returned once", func(t *testing.T) { + t.Parallel() + + repoMetadataResult := RepoMetadataResult{ + AssignableUsers: []AssignableUser{ + NewAssignableUser("MONAID", "monalisa", ""), + }, + AssignableActors: []AssignableActor{ + NewAssignableUser("MONAID", "monalisa", ""), + }, + } + ids, err := repoMetadataResult.MembersToIDs([]string{"monalisa"}) + require.NoError(t, err) + require.Equal(t, []string{"MONAID"}, ids) + }) + + t.Run("when id is not found, returns an error", func(t *testing.T) { + t.Parallel() + + repoMetadataResult := RepoMetadataResult{} + _, err := repoMetadataResult.MembersToIDs([]string{"monalisa"}) + require.Error(t, err) + }) +} + func sliceEqual(a, b []string) bool { if len(a) != len(b) { return false From 1eee56ec00ae1ff0c08e03adcd0d7a4c79dd79aa Mon Sep 17 00:00:00 2001 From: Dylan Ancel Date: Wed, 11 Jun 2025 16:28:03 +0200 Subject: [PATCH 227/249] Add accurate context when `run rerun` fails (#10774) * Add accurate context when run rerun fails * Update tests to verify behaviour for API errors Signed-off-by: Babak K. Shandiz * Use the new `httpmock.JSONErrorResponse` helper Signed-off-by: Babak K. Shandiz --------- Signed-off-by: Babak K. Shandiz Co-authored-by: Babak K. Shandiz --- pkg/cmd/run/rerun/rerun.go | 5 +---- pkg/cmd/run/rerun/rerun_test.go | 39 ++++++++++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/run/rerun/rerun.go b/pkg/cmd/run/rerun/rerun.go index 66d5d6f64..8777e0a8a 100644 --- a/pkg/cmd/run/rerun/rerun.go +++ b/pkg/cmd/run/rerun/rerun.go @@ -202,10 +202,7 @@ func rerunRun(client *api.Client, repo ghrepo.Interface, run *shared.Run, onlyFa if err != nil { var httpError api.HTTPError if errors.As(err, &httpError) && httpError.StatusCode == 403 { - if httpError.Message == "Unable to retry this workflow run because it was created over a month ago" { - return fmt.Errorf("run %d cannot be rerun; %s", run.ID, httpError.Message) - } - return fmt.Errorf("run %d cannot be rerun; its workflow file may be broken", run.ID) + return fmt.Errorf("run %d cannot be rerun; %s", run.ID, httpError.Message) } return fmt.Errorf("failed to rerun: %w", err) } diff --git a/pkg/cmd/run/rerun/rerun_test.go b/pkg/cmd/run/rerun/rerun_test.go index 0dc74129e..77f0a922f 100644 --- a/pkg/cmd/run/rerun/rerun_test.go +++ b/pkg/cmd/run/rerun/rerun_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/go-gh/v2/pkg/api" "github.com/google/shlex" "github.com/stretchr/testify/assert" ) @@ -374,7 +375,7 @@ func TestRerun(t *testing.T) { errOut: "no recent runs have failed; please specify a specific ``", }, { - name: "unrerunnable", + name: "API error (403)", tty: true, opts: &RerunOptions{ RunID: "3", @@ -392,10 +393,42 @@ func TestRerun(t *testing.T) { })) reg.Register( httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/3/rerun"), - httpmock.StatusStringResponse(403, "no")) + httpmock.JSONErrorResponse(403, api.HTTPError{ + StatusCode: 403, + Message: "blah blah", + }), + ) }, wantErr: true, - errOut: "run 3 cannot be rerun; its workflow file may be broken", + errOut: "run 3 cannot be rerun; blah blah", + }, + { + name: "API error (non-403)", + tty: true, + opts: &RerunOptions{ + RunID: "3", + }, + 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", "repos/OWNER/REPO/actions/workflows/123"), + httpmock.JSONResponse(workflowShared.WorkflowsPayload{ + Workflows: []workflowShared.Workflow{ + shared.TestWorkflow, + }, + })) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/3/rerun"), + httpmock.JSONErrorResponse(500, api.HTTPError{ + StatusCode: 500, + Message: "blah blah", + }), + ) + }, + wantErr: true, + errOut: "failed to rerun: HTTP 500: blah blah (https://api.github.com/repos/OWNER/REPO/actions/runs/3/rerun)", }, } From f8a31330031b66716fa10c8fbdfb1f3d005e5f3d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:33:02 +0000 Subject: [PATCH 228/249] chore(deps): bump actions/attest-build-provenance from 2.3.0 to 2.4.0 Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 2.3.0 to 2.4.0. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/db473fddc028af60658334401dc6fa3ffd8669fd...e8998f949152b193b063cb0ec769d69d929409be) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-version: 2.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/deployment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index d5cc67ec9..17758a3e6 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -309,7 +309,7 @@ jobs: rpmsign --addsign dist/*.rpm - name: Attest release artifacts if: inputs.environment == 'production' - uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 with: subject-path: "dist/gh_*" - name: Run createrepo From 928a326cee78313df3fc7085e2e113edc06bc98c Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 16 Jun 2025 17:09:04 +0200 Subject: [PATCH 229/249] Add workflow to check `help wanted` labelling (#11105) Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> Co-authored-by: Babak K. Shandiz Co-authored-by: Andy Feller --- .github/workflows/codeql.yml | 9 +- .github/workflows/pr-help-wanted.yml | 29 ++++++ .../workflows/scripts/check-help-wanted.sh | 93 +++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pr-help-wanted.yml create mode 100755 .github/workflows/scripts/check-help-wanted.sh diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8cd5ecbee..d74e1c142 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,6 +18,10 @@ permissions: jobs: CodeQL-Build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: ['go', 'actions'] steps: - name: Check out code @@ -26,13 +30,16 @@ jobs: - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: - languages: go + languages: ${{ matrix.language }} queries: security-and-quality - name: Setup Go + if: matrix.language == 'go' uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/pr-help-wanted.yml b/.github/workflows/pr-help-wanted.yml new file mode 100644 index 000000000..5475d2eff --- /dev/null +++ b/.github/workflows/pr-help-wanted.yml @@ -0,0 +1,29 @@ +name: PR Help Wanted Check +on: + pull_request_target: + types: [opened] + +permissions: + contents: none + issues: read + pull-requests: write + +jobs: + check-help-wanted: + runs-on: ubuntu-latest + steps: + - name: Check for issues without help-wanted label + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} + if: !github.event.pull_request.draft + run: | + # Skip if PR is from a bot or org member + if [ "$PR_AUTHOR_TYPE" = "Bot" ] || "gh api orgs/cli/public_members/${PR_AUTHOR}" --silent 2>/dev/null + then + exit 0 + fi + + # Run the script to check for issues without help-wanted label + bash .github/scripts/check-help-wanted.sh ${{ github.event.pull_request.html_url }} diff --git a/.github/workflows/scripts/check-help-wanted.sh b/.github/workflows/scripts/check-help-wanted.sh new file mode 100755 index 000000000..59c12fecd --- /dev/null +++ b/.github/workflows/scripts/check-help-wanted.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +set -e + +PR_URL="$1" + +if [ -z "$PR_URL" ]; then + echo "Usage: $0 " + echo "" + echo "Check if the PR references any non-help-wanted issues and, if so, comment" + echo "on it explaining why the team might close/dismiss it." + exit 1 +fi + +# Extract PR number from URL for logging +PR_NUM="$(basename "$PR_URL")" + +# Extract cli/cli closing issues references from PR +CLOSING_ISSUES="$(gh pr view "$PR_URL" --json closingIssuesReferences --jq '.closingIssuesReferences[] | select(.repository.name == "cli" and .repository.owner.login == "cli") | .number')" + +if [ -z "$CLOSING_ISSUES" ]; then + echo "No closing issues found for PR #$PR_NUM" + exit 0 +fi + +# Check each closing issue for 'help-wanted' label +ISSUES_WITHOUT_HELP_WANTED=() + +for issue_num in $CLOSING_ISSUES; do + echo "Checking issue #$issue_num for 'help wanted' label..." + + # Get issue labels + LABELS=$(gh issue view "$issue_num" --json labels --jq '.labels[].name') + + # Skip if the issue has the gh-attestion or gh-codespace label + # This is because the codeowners for these commands may not be public + # cli org members, and so unless we authenticate with a PAT, we can't + # know who is an external contributor or not. + # So we skip these issues to avoid falsely writing a comment + # on each PR opened by these codeowners. + if echo "$LABELS" | grep -q -e "gh-attestation" -e "gh-codespace"; then + echo "Issue #$issue_num is skipped due to labels" + continue + fi + + # Check if 'help wanted' label exists + if ! echo "$LABELS" | grep -q "help wanted"; then + ISSUES_WITHOUT_HELP_WANTED+=("$issue_num") + echo "Issue #$issue_num does not have 'help wanted' label" + else + echo "Issue #$issue_num has 'help wanted' label" + fi +done + +# If we found issues without 'help wanted' label, post a comment +if [ ${#ISSUES_WITHOUT_HELP_WANTED[@]} -gt 0 ]; then + echo "Found ${#ISSUES_WITHOUT_HELP_WANTED[@]} issues without 'help wanted' label" + + # Build issue list for comment + ISSUE_LIST="" + for issue_num in "${ISSUES_WITHOUT_HELP_WANTED[@]}"; do + ISSUE_LIST="$ISSUE_LIST- #$issue_num"$'\n' + done + + # Create comment message + gh pr comment "$PR_URL" --body-file - < Date: Mon, 16 Jun 2025 17:53:43 +0200 Subject: [PATCH 230/249] Quote workflow conditional --- .github/workflows/pr-help-wanted.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-help-wanted.yml b/.github/workflows/pr-help-wanted.yml index 5475d2eff..d11173645 100644 --- a/.github/workflows/pr-help-wanted.yml +++ b/.github/workflows/pr-help-wanted.yml @@ -17,7 +17,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} - if: !github.event.pull_request.draft + if: "!github.event.pull_request.draft" run: | # Skip if PR is from a bot or org member if [ "$PR_AUTHOR_TYPE" = "Bot" ] || "gh api orgs/cli/public_members/${PR_AUTHOR}" --silent 2>/dev/null From 32a7400e228c3df7eeead2dc4df888b026272d5d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 16:02:19 +0000 Subject: [PATCH 231/249] chore(deps): bump github.com/in-toto/attestation from 1.1.1 to 1.1.2 Bumps [github.com/in-toto/attestation](https://github.com/in-toto/attestation) from 1.1.1 to 1.1.2. - [Release notes](https://github.com/in-toto/attestation/releases) - [Commits](https://github.com/in-toto/attestation/compare/v1.1.1...v1.1.2) --- updated-dependencies: - dependency-name: github.com/in-toto/attestation dependency-version: 1.1.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 289fd83a5..66383962f 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( 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/in-toto/attestation v1.1.2 github.com/joho/godotenv v1.5.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/mattn/go-colorable v0.1.14 diff --git a/go.sum b/go.sum index 95f7ed75d..094016356 100644 --- a/go.sum +++ b/go.sum @@ -304,8 +304,8 @@ github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/in-toto/attestation v1.1.1 h1:QD3d+oATQ0dFsWoNh5oT0udQ3tUrOsZZ0Fc3tSgWbzI= -github.com/in-toto/attestation v1.1.1/go.mod h1:Dcq1zVwA2V7Qin8I7rgOi+i837wEf/mOZwRm047Sjys= +github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj/V1E= +github.com/in-toto/attestation v1.1.2/go.mod h1:gYFddHMZj3DiQ0b62ltNi1Vj5rC879bTmBbrv9CRHpM= github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU= github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= From da9ed6a3368b3db53ed716f7b921b5b70008b6de Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 16 Jun 2025 18:02:47 +0200 Subject: [PATCH 232/249] Quote filenames suggested at the end of worklow run --- pkg/cmd/workflow/run/run.go | 2 +- pkg/cmd/workflow/run/run_test.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index def3cf9e4..2acd1d4cc 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -330,7 +330,7 @@ func runRun(opts *RunOptions) error { fmt.Fprintln(out) fmt.Fprintf(out, "To see runs for this workflow, try: %s\n", - cs.Boldf("gh run list --workflow=%s", workflow.Base())) + cs.Boldf("gh run list --workflow=%q", workflow.Base())) } return nil diff --git a/pkg/cmd/workflow/run/run_test.go b/pkg/cmd/workflow/run/run_test.go index ee1bc5a1e..b121a573d 100644 --- a/pkg/cmd/workflow/run/run_test.go +++ b/pkg/cmd/workflow/run/run_test.go @@ -447,7 +447,7 @@ jobs: "ref": "trunk", }, httpStubs: stubs, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=workflow.yml\n", + wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", }, { name: "nontty good JSON", @@ -494,7 +494,7 @@ jobs: "ref": "good-branch", }, httpStubs: stubs, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at good-branch\n\nTo see runs for this workflow, try: gh run list --workflow=workflow.yml\n", + wantOut: "✓ Created workflow_dispatch event for workflow.yml at good-branch\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", }, { // TODO this test is somewhat silly; it's more of a placeholder in case I decide to handle the API error more elegantly @@ -634,7 +634,7 @@ jobs: "inputs": map[string]interface{}{}, "ref": "trunk", }, - wantOut: "✓ Created workflow_dispatch event for minimal.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=minimal.yml\n", + wantOut: "✓ Created workflow_dispatch event for minimal.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"minimal.yml\"\n", }, { name: "prompt", @@ -682,7 +682,7 @@ jobs: }, "ref": "trunk", }, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=workflow.yml\n", + wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", }, { name: "prompt, workflow choice input", @@ -731,7 +731,7 @@ jobs: }, "ref": "trunk", }, - wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=workflow.yml\n", + wantOut: "✓ Created workflow_dispatch event for workflow.yml at trunk\n\nTo see runs for this workflow, try: gh run list --workflow=\"workflow.yml\"\n", }, { name: "prompt, workflow choice missing input", From 7fa213251c305d7d6f99cdbd9bef17b6d3dc2603 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 16 Jun 2025 10:14:38 -0600 Subject: [PATCH 233/249] Fix script path for help-wanted check --- .github/workflows/pr-help-wanted.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-help-wanted.yml b/.github/workflows/pr-help-wanted.yml index 5475d2eff..0dd10c647 100644 --- a/.github/workflows/pr-help-wanted.yml +++ b/.github/workflows/pr-help-wanted.yml @@ -26,4 +26,4 @@ jobs: fi # Run the script to check for issues without help-wanted label - bash .github/scripts/check-help-wanted.sh ${{ github.event.pull_request.html_url }} + bash .github/workflows/scripts/check-help-wanted.sh ${{ github.event.pull_request.html_url }} From c7b1afd293f73d09664ce4333492c203c827256b Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Mon, 16 Jun 2025 13:36:37 -0400 Subject: [PATCH 234/249] Fixes #11126 These changes will cause GitHub Advanced Security to ignore the auto-generated content around 3rd party dependencies used by `cli/cli` from static code analysis and secret scanning. For more information: - https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning - https://docs.github.com/en/code-security/secret-scanning/using-advanced-secret-scanning-and-push-protection-features/excluding-folders-and-files-from-secret-scanning --- .github/secret_scanning.yml | 3 +++ .github/workflows/codeql.yml | 4 ++++ 2 files changed, 7 insertions(+) create mode 100644 .github/secret_scanning.yml diff --git a/.github/secret_scanning.yml b/.github/secret_scanning.yml new file mode 100644 index 000000000..83ee7b460 --- /dev/null +++ b/.github/secret_scanning.yml @@ -0,0 +1,3 @@ +paths-ignore: + - 'third-party/**' + - 'third-party-licenses.*.md' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d74e1c142..37bbb0607 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -32,6 +32,10 @@ jobs: with: languages: ${{ matrix.language }} queries: security-and-quality + config: | + paths-ignore: + - 'third-party/**' + - 'third-party-licenses.*.md' - name: Setup Go if: matrix.language == 'go' From 7d1b5d2ce09242bd1d1716d50789b81937e1faf5 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 16 Jun 2025 11:47:48 -0600 Subject: [PATCH 235/249] Fix repo checkout in help-wanted check --- .github/workflows/pr-help-wanted.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pr-help-wanted.yml b/.github/workflows/pr-help-wanted.yml index 0dd10c647..0d7245836 100644 --- a/.github/workflows/pr-help-wanted.yml +++ b/.github/workflows/pr-help-wanted.yml @@ -12,6 +12,9 @@ jobs: check-help-wanted: runs-on: ubuntu-latest steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Check for issues without help-wanted label env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 31b3dc1ba6c77ca446b8b5c210864db5a0070ebe Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 16 Jun 2025 20:46:24 +0200 Subject: [PATCH 236/249] Ensure gh executes in workflow check script --- .github/workflows/pr-help-wanted.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/pr-help-wanted.yml b/.github/workflows/pr-help-wanted.yml index 5475d2eff..0b86cae85 100644 --- a/.github/workflows/pr-help-wanted.yml +++ b/.github/workflows/pr-help-wanted.yml @@ -20,8 +20,7 @@ jobs: if: !github.event.pull_request.draft run: | # Skip if PR is from a bot or org member - if [ "$PR_AUTHOR_TYPE" = "Bot" ] || "gh api orgs/cli/public_members/${PR_AUTHOR}" --silent 2>/dev/null - then + if [ "$PR_AUTHOR_TYPE" = "Bot" ] || gh api orgs/cli/public_members/"${PR_AUTHOR}" --silent 2>/dev/null; then exit 0 fi From fa3402f7834b4ef8265b40a328f9d068e1076728 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 16 Jun 2025 12:53:45 -0600 Subject: [PATCH 237/249] Improve help wanted check skipping logic --- .github/workflows/pr-help-wanted.yml | 6 +----- .github/workflows/scripts/check-help-wanted.sh | 6 ++++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr-help-wanted.yml b/.github/workflows/pr-help-wanted.yml index 313e91e14..3482ecd08 100644 --- a/.github/workflows/pr-help-wanted.yml +++ b/.github/workflows/pr-help-wanted.yml @@ -20,12 +20,8 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} + PR_AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }} if: "!github.event.pull_request.draft" run: | - # Skip if PR is from a bot or org member - if [ "$PR_AUTHOR_TYPE" = "Bot" ] || gh api orgs/cli/public_members/"${PR_AUTHOR}" --silent 2>/dev/null; then - exit 0 - fi - # Run the script to check for issues without help-wanted label bash .github/workflows/scripts/check-help-wanted.sh ${{ github.event.pull_request.html_url }} diff --git a/.github/workflows/scripts/check-help-wanted.sh b/.github/workflows/scripts/check-help-wanted.sh index 59c12fecd..5c3223761 100755 --- a/.github/workflows/scripts/check-help-wanted.sh +++ b/.github/workflows/scripts/check-help-wanted.sh @@ -12,6 +12,12 @@ if [ -z "$PR_URL" ]; then exit 1 fi +# Skip if PR is from a bot or org member +if [ "$PR_AUTHOR_TYPE" = "Bot" ] || [ "$PR_AUTHOR_ASSOCIATION" = "MEMBER" ]; then + echo "Skipping check for PR #$PR_URL as it is from a bot ($PR_AUTHOR_TYPE) or an org member ($PR_AUTHOR_ASSOCIATION)" + exit 0 +fi + # Extract PR number from URL for logging PR_NUM="$(basename "$PR_URL")" From 9e161cda4e69de3845f29787fd00718e184b5f03 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 16 Jun 2025 13:20:20 -0600 Subject: [PATCH 238/249] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/scripts/check-help-wanted.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/scripts/check-help-wanted.sh b/.github/workflows/scripts/check-help-wanted.sh index 5c3223761..d316e8bde 100755 --- a/.github/workflows/scripts/check-help-wanted.sh +++ b/.github/workflows/scripts/check-help-wanted.sh @@ -13,8 +13,8 @@ if [ -z "$PR_URL" ]; then fi # Skip if PR is from a bot or org member -if [ "$PR_AUTHOR_TYPE" = "Bot" ] || [ "$PR_AUTHOR_ASSOCIATION" = "MEMBER" ]; then - echo "Skipping check for PR #$PR_URL as it is from a bot ($PR_AUTHOR_TYPE) or an org member ($PR_AUTHOR_ASSOCIATION)" +if [ "$PR_AUTHOR_TYPE" = "Bot" ] || [ "$PR_AUTHOR_ASSOCIATION" = "MEMBER" ] || [ "$PR_AUTHOR_ASSOCIATION" = "OWNER" ]; then + echo "Skipping check for PR #$PR_URL as it is from a bot ($PR_AUTHOR_TYPE) or an org member ($PR_AUTHOR_ASSOCIATION: MEMBER/OWNER)" exit 0 fi From 86c251154e88b32362761ea5f1fa71a85e1d25fb Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 17 Jun 2025 11:43:14 +0200 Subject: [PATCH 239/249] Merge pull request #11121 from cli/11101-use-golangci-lint-version-2 Bump golangci-lint to v2 --- .github/workflows/lint.yml | 22 ++++++---------------- .golangci.yml | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f1ae1e522..48e8539d1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,17 +27,7 @@ jobs: with: go-version-file: 'go.mod' - - name: Verify dependencies - run: | - go mod verify - go mod download - - LINT_VERSION=1.63.4 - curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \ - tar xz --strip-components 1 --wildcards \*/golangci-lint - mkdir -p bin && mv golangci-lint bin/ - - - name: Run checks + - name: Ensure go.mod and go.sum are up to date run: | STATUS=0 assert-nothing-changed() { @@ -49,10 +39,10 @@ jobs: STATUS=1 fi } - - assert-nothing-changed go fmt ./... assert-nothing-changed go mod tidy - - bin/golangci-lint run --out-format=colored-line-number --timeout=3m || STATUS=$? - exit $STATUS + + - name: golangci-lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + with: + version: v2.1.6 diff --git a/.golangci.yml b/.golangci.yml index ff7f37014..861198dd8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,17 @@ +version: "2" + linters: enable: - - gofmt - - nolintlint + - nolintlint + disable: + # The following linters are disabled purely because this config was migrated to v2 where they are in the default + # set, and we should have separate work to enable them if we truly want them. + - staticcheck + - errcheck + +formatters: + enable: + - gofmt issues: max-issues-per-linter: 0 From b4cd2273d857208ff3be0d469da2563985fd188c Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 17 Jun 2025 11:13:03 +0200 Subject: [PATCH 240/249] Bump to Go 1.24 --- go.mod | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 66383962f..238a06eb1 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/cli/cli/v2 -go 1.23.0 +go 1.24 -toolchain go1.23.5 +toolchain go1.24.4 require ( github.com/AlecAivazis/survey/v2 v2.3.7 From 49ed6c46810bc4d62dbe89ba48e0cd2964fc6324 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 17 Jun 2025 11:13:45 +0200 Subject: [PATCH 241/249] Use t.Chdir in tests --- pkg/cmd/extension/command_test.go | 7 +++--- pkg/cmd/extension/manager_test.go | 27 ++++++++--------------- pkg/cmd/release/download/download_test.go | 11 +-------- pkg/cmdutil/args_test.go | 15 ++----------- 4 files changed, 15 insertions(+), 45 deletions(-) diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index 76741714a..7001c8f1a 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -23,15 +23,14 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewCmdExtension(t *testing.T) { tempDir := t.TempDir() - oldWd, _ := os.Getwd() localExtensionTempDir := filepath.Join(tempDir, "gh-hello") - assert.NoError(t, os.MkdirAll(localExtensionTempDir, 0755)) - assert.NoError(t, os.Chdir(localExtensionTempDir)) - t.Cleanup(func() { _ = os.Chdir(oldWd) }) + require.NoError(t, os.MkdirAll(localExtensionTempDir, 0755)) + t.Chdir(localExtensionTempDir) tests := []struct { name string diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index 167af3439..e933f0bdf 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -1251,9 +1251,10 @@ func TestManager_repo_not_found(t *testing.T) { } func TestManager_Create(t *testing.T) { - chdirTemp(t) + tempDir := t.TempDir() + t.Chdir(tempDir) err := os.MkdirAll("gh-test", 0755) - assert.NoError(t, err) + require.NoError(t, err) ios, _, stdout, stderr := iostreams.Test() @@ -1279,9 +1280,10 @@ func TestManager_Create(t *testing.T) { } func TestManager_Create_go_binary(t *testing.T) { - chdirTemp(t) + tempDir := t.TempDir() + t.Chdir(tempDir) err := os.MkdirAll("gh-test", 0755) - assert.NoError(t, err) + require.NoError(t, err) reg := httpmock.Registry{} defer reg.Verify(t) @@ -1329,9 +1331,10 @@ func TestManager_Create_go_binary(t *testing.T) { } func TestManager_Create_other_binary(t *testing.T) { - chdirTemp(t) + tempDir := t.TempDir() + t.Chdir(tempDir) err := os.MkdirAll("gh-test", 0755) - assert.NoError(t, err) + require.NoError(t, err) ios, _, stdout, stderr := iostreams.Test() @@ -1392,18 +1395,6 @@ func Test_ensurePrefixed(t *testing.T) { } } -// chdirTemp changes the current working directory to a temporary directory for the duration of the test. -func chdirTemp(t *testing.T) { - oldWd, _ := os.Getwd() - tempDir := t.TempDir() - if err := os.Chdir(tempDir); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - _ = os.Chdir(oldWd) - }) -} - func fileNames(files []os.DirEntry) []string { names := make([]string, len(files)) for i, f := range files { diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go index 9337c9b65..37c0e3c02 100644 --- a/pkg/cmd/release/download/download_test.go +++ b/pkg/cmd/release/download/download_test.go @@ -174,11 +174,6 @@ func Test_NewCmdDownload(t *testing.T) { } func Test_downloadRun(t *testing.T) { - oldwd, err := os.Getwd() - if err != nil { - t.Fatalf("could not determine working directory: %v", err) - } - tests := []struct { name string isTTY bool @@ -526,11 +521,7 @@ func Test_downloadRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tempDir := t.TempDir() - if err := os.Chdir(tempDir); err == nil { - t.Cleanup(func() { _ = os.Chdir(oldwd) }) - } else { - t.Fatal(err) - } + t.Chdir(tempDir) ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.isTTY) diff --git a/pkg/cmdutil/args_test.go b/pkg/cmdutil/args_test.go index 4e880dd27..58f0dc0b6 100644 --- a/pkg/cmdutil/args_test.go +++ b/pkg/cmdutil/args_test.go @@ -210,17 +210,10 @@ func createTestDir(t *testing.T) (cleanupFn func()) { rootDir := t.TempDir() // Move workspace to temporary directory - cwd, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - err = os.Chdir(rootDir) - if err != nil { - t.Fatal(err) - } + t.Chdir(rootDir) // Make subdirectories - err = os.Mkdir(filepath.Join(rootDir, "subDir1"), 0755) + err := os.Mkdir(filepath.Join(rootDir, "subDir1"), 0755) if err != nil { t.Fatal(err) } @@ -253,10 +246,6 @@ func createTestDir(t *testing.T) (cleanupFn func()) { cleanupFn = func() { os.RemoveAll(rootDir) - err = os.Chdir(cwd) - if err != nil { - t.Fatal(err) - } } return cleanupFn } From f55443d7b02810c96c3ceb57695fc2c08cc27c37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 10:05:53 +0000 Subject: [PATCH 242/249] chore(deps): bump github.com/google/go-containerregistry Bumps [github.com/google/go-containerregistry](https://github.com/google/go-containerregistry) from 0.20.3 to 0.20.6. - [Release notes](https://github.com/google/go-containerregistry/releases) - [Changelog](https://github.com/google/go-containerregistry/blob/main/.goreleaser.yml) - [Commits](https://github.com/google/go-containerregistry/compare/v0.20.3...v0.20.6) --- updated-dependencies: - dependency-name: github.com/google/go-containerregistry dependency-version: 0.20.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 34 ++++++++++++------------- go.sum | 80 +++++++++++++++++++++++++++++----------------------------- 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/go.mod b/go.mod index 238a06eb1..e5d499aee 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/gdamore/tcell/v2 v2.5.4 github.com/golang/snappy v0.0.4 github.com/google/go-cmp v0.7.0 - github.com/google/go-containerregistry v0.20.3 + github.com/google/go-containerregistry v0.20.6 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 @@ -52,10 +52,10 @@ require ( github.com/theupdateframework/go-tuf/v2 v2.1.1 github.com/yuin/goldmark v1.7.12 github.com/zalando/go-keyring v0.2.5 - golang.org/x/crypto v0.38.0 - golang.org/x/sync v0.14.0 + golang.org/x/crypto v0.39.0 + golang.org/x/sync v0.15.0 golang.org/x/term v0.32.0 - golang.org/x/text v0.25.0 + golang.org/x/text v0.26.0 google.golang.org/grpc v1.72.2 google.golang.org/protobuf v1.36.6 gopkg.in/h2non/gock.v1 v1.1.2 @@ -86,13 +86,13 @@ require ( github.com/cli/shurcooL-graphql v0.0.4 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20220623050100-57a0ce2678a7 // indirect - github.com/danieljoos/wincred v1.2.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect - github.com/docker/cli v27.5.0+incompatible // indirect + github.com/docker/cli v28.2.2+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect - github.com/docker/docker-credential-helpers v0.8.2 // indirect + github.com/docker/docker-credential-helpers v0.9.3 // 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 @@ -100,7 +100,7 @@ require ( 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/logr v1.4.3 // 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.1 // indirect @@ -126,7 +126,7 @@ require ( github.com/itchyny/timefmt-go v0.1.5 // indirect github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect @@ -144,7 +144,7 @@ require ( github.com/muesli/termenv v0.16.0 // indirect 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/opencontainers/image-spec v1.1.1 // 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 @@ -171,21 +171,21 @@ require ( github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect - github.com/vbatts/tar-split v0.11.6 // indirect + github.com/vbatts/tar-split v0.12.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 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.35.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.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-20240531132922-fd00a4e0eefc // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/net v0.40.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.41.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/tools v0.29.0 // indirect + golang.org/x/tools v0.34.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect k8s.io/klog/v2 v2.130.1 // indirect diff --git a/go.sum b/go.sum index 094016356..f0f2bb5ed 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU= cloud.google.com/go/auth v0.16.0/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -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/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= cloud.google.com/go/iam v1.5.0 h1:QlLcVMhbLGOjRcGe6VTGGTyQib8dRLK2B/kYNV0+2xs= cloud.google.com/go/iam v1.5.0/go.mod h1:U+DOtKQltF/LxPEtcDLoobcsZMilSRwR7mgNL7knOpo= cloud.google.com/go/kms v1.21.2 h1:c/PRUSMNQ8zXrc1sdAUnsenWWaNXN+PzTXfXOcSFdoE= @@ -160,8 +160,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyberphone/json-canonicalization v0.0.0-20220623050100-57a0ce2678a7 h1:vU+EP9ZuFUCYE0NYLwTSob+3LNEJATzNfP/DC7SWGWI= github.com/cyberphone/json-canonicalization v0.0.0-20220623050100-57a0ce2678a7/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= -github.com/danieljoos/wincred v1.2.1 h1:dl9cBrupW8+r5250DYkYxocLeZ1Y4vB1kxgtjxw8GQs= -github.com/danieljoos/wincred v1.2.1/go.mod h1:uGaFL9fDn3OLTvzCGulzE+SzjEe5NGlh5FdCcyfPwps= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -175,12 +175,12 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v27.5.0+incompatible h1:aMphQkcGtpHixwwhAXJT1rrK/detk2JIvDaFkLctbGM= -github.com/docker/cli v27.5.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= +github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= 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/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= 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= @@ -205,8 +205,8 @@ github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxm 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= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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= @@ -247,8 +247,8 @@ github.com/google/certificate-transparency-go v1.3.1 h1:akbcTfQg0iZlANZLn0L9xOeW github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= 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/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= +github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= 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.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -338,8 +338,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -402,8 +402,8 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 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.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= @@ -515,8 +515,8 @@ github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C 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= github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= -github.com/vbatts/tar-split v0.11.6 h1:4SjTW5+PU11n6fZenf2IPoV8/tz3AaYHMWjf23envGs= -github.com/vbatts/tar-split v0.11.6/go.mod h1:dqKNtesIOr2j2Qv3W/cHjnvk9I8+G7oAkFDFN6TCBEI= +github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= +github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -533,18 +533,18 @@ go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJyS go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= +go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= +go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= +go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= 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.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= +go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.step.sm/crypto v0.63.0 h1:U1QGELQqJ85oDfeNFE2V52cow1rvy0m3MekG3wFmyXY= go.step.sm/crypto v0.63.0/go.mod h1:aj3LETmCZeSil1DMq3BlbhDBcN86+mmKrHZtXWyc0L4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -555,24 +555,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.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc h1:O9NuF4s+E/PvMIy+9IUZB9znFwUIXEWSstNjek6VpVg= golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -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/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.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.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= -golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= -golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= +golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 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.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.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= @@ -594,15 +594,15 @@ 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.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 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/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.230.0 h1:2u1hni3E+UXAXrONrrkfWpi/V6cyKVAbfGVeGtC3OxM= google.golang.org/api v0.230.0/go.mod h1:aqvtoMk7YkiXx+6U12arQFExiRV9D/ekvMCwCd/TksQ= From 1ac3f064f3ef4d94f6ea4962862a13913e0af725 Mon Sep 17 00:00:00 2001 From: Vibhakar Solanki Date: Tue, 6 May 2025 18:10:50 +0530 Subject: [PATCH 243/249] docs: update install command for Debian Make sure that `sources.list.d` exists before trying to populate `github-cli.list` --- docs/install_linux.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/install_linux.md b/docs/install_linux.md index 43ba876a1..b1e9ac829 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -19,6 +19,7 @@ Install: && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \ && cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && sudo mkdir -p -m 755 /etc/apt/sources.list.d \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ && sudo apt update \ && sudo apt install gh -y From 50e4a4ad15c9f87d4600f24d1c1c17d7a6050928 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 17 Jun 2025 08:41:14 -0600 Subject: [PATCH 244/249] Fix step order for CodeQL workflow --- .github/workflows/codeql.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 37bbb0607..06d9bc81f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,6 +27,12 @@ jobs: - name: Check out code uses: actions/checkout@v4 + - name: Setup Go + if: matrix.language == 'go' + uses: actions/setup-go@v5 + with: + go-version-file: "go.mod" + - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: @@ -37,12 +43,6 @@ jobs: - 'third-party/**' - 'third-party-licenses.*.md' - - name: Setup Go - if: matrix.language == 'go' - uses: actions/setup-go@v5 - with: - go-version-file: 'go.mod' - - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 with: From 6b36060d586b55b46ee52f898f3d637333cddfba Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 17 Jun 2025 16:43:25 +0200 Subject: [PATCH 245/249] Update docs/install_linux.md --- docs/install_linux.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install_linux.md b/docs/install_linux.md index 09342c802..719a9f2d0 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -165,7 +165,7 @@ pkg install gh ### MidnightBSD -MidnightBSD users can install from mports +MidnightBSD users can install from [mports](https://www.midnightbsd.org/documentation/mports/index.html) ```bash cd /usr/mports/devel/gh/ && make install clean From 28d5de9d8ebff954de47a36be5d5c92b92d9d18c Mon Sep 17 00:00:00 2001 From: jinjingroad Date: Thu, 19 Jun 2025 12:36:52 +0800 Subject: [PATCH 246/249] chore: fix function name Signed-off-by: jinjingroad --- pkg/cmd/pr/shared/find_refs_resolution.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/find_refs_resolution.go b/pkg/cmd/pr/shared/find_refs_resolution.go index e4e51bab8..4b977c716 100644 --- a/pkg/cmd/pr/shared/find_refs_resolution.go +++ b/pkg/cmd/pr/shared/find_refs_resolution.go @@ -129,7 +129,7 @@ func NewPullRequestFindRefsResolver(gitConfigClient GitConfigClient, remotesFn f } } -// ResolvePullRequests takes a base repository, a base branch name and a local branch name and uses the git configuration to +// ResolvePullRequestRefs 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) { From 169b909015a9ef103c9714e0283e5db346ee3a15 Mon Sep 17 00:00:00 2001 From: ejahnGithub Date: Thu, 19 Jun 2025 10:18:23 -0400 Subject: [PATCH 247/249] improve the description for gh release verify cmd --- pkg/cmd/release/verify-asset/verify_asset.go | 37 +++++++++++++++++--- pkg/cmd/release/verify/verify.go | 30 ++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/release/verify-asset/verify_asset.go b/pkg/cmd/release/verify-asset/verify_asset.go index ddafbb265..2b66f3502 100644 --- a/pkg/cmd/release/verify-asset/verify_asset.go +++ b/pkg/cmd/release/verify-asset/verify_asset.go @@ -14,6 +14,7 @@ import ( att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/release/shared" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -37,10 +38,34 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*VerifyAssetConfig) error) opts := &VerifyAssetOptions{} cmd := &cobra.Command{ - Use: "verify-asset ", - Short: "Verify that a given asset originated from a specific GitHub Release.", + Use: "verify-asset [] ", + Short: "Verify that a given asset originated from a specific GitHub Release.", + Long: heredoc.Doc(` + Verify that a given asset file originated from a specific GitHub Release using cryptographically signed attestations. + + ## Understanding Verification + + An attestation is a claim made by GitHub regarding a release and its assets. + + ## What This Command Does + + This command checks that the asset you provide matches an attestation produced by GitHub for a particular release. + It ensures the asset's integrity by validating: + * The asset's digest matches the subject in the attestation + * The attestation is associated with the specified release + `), Hidden: true, Args: cobra.MaximumNArgs(2), + Example: heredoc.Doc(` + # Verify an asset from the latest release + $ gh release verify-asset ./dist/my-asset.zip + + # Verify an asset from a specific release tag + $ gh release verify-asset v1.2.3 ./dist/my-asset.zip + + # Verify an asset from a specific release tag and output the attestation in JSON format + $ gh release verify-asset v1.2.3 ./dist/my-asset.zip --format json + `), RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 2 { opts.TagName = args[0] @@ -122,13 +147,17 @@ func verifyAssetRun(config *VerifyAssetConfig) error { releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") - // Find attestaitons for the release tag SHA + // Find attestations for the release tag SHA attestations, err := config.AttClient.GetByDigest(api.FetchParams{ Digest: releaseRefDigest.DigestWithAlg(), PredicateType: shared.ReleasePredicateType, Owner: baseRepo.RepoOwner(), Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), - Limit: 10, + // TODO: Allow this value to be set via a flag. + // The limit is set to 100 to ensure we fetch all attestations for a given SHA. + // While multiple attestations can exist for a single SHA, + // only one attestation is associated with each release tag. + Limit: 100, }) if err != nil { return fmt.Errorf("no attestations found for tag %s (%s)", tagName, releaseRefDigest.DigestWithAlg()) diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 95708fa5a..8c04fe682 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "github.com/MakeNowJust/heredoc" v1 "github.com/in-toto/attestation/go/v1" "google.golang.org/protobuf/encoding/protojson" @@ -43,7 +44,28 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(config *VerifyConfig) error) *co Short: "Verify the attestation for a GitHub Release.", Hidden: true, Args: cobra.MaximumNArgs(1), + Long: heredoc.Doc(` + Verify that a GitHub Release is accompanied by a valid cryptographically signed attestation. + ## Understanding Verification + + An attestation is a claim made by GitHub regarding a release and its assets. + + ## What This Command Does + + This command checks that the specified release (or the latest release, if no tag is given) has a valid attestation. + It fetches the attestation for the release and prints out metadata about all assets referenced in the attestation, including their digests. + `), + Example: heredoc.Doc(` + # Verify the latest release + gh release verify + + # Verify a specific release by tag + gh release verify v1.2.3 + + # Verify a specific release by tag and output the attestation in JSON format + gh release verify v1.2.3 --format json + `), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { opts.TagName = args[0] @@ -111,13 +133,17 @@ func verifyRun(config *VerifyConfig) error { releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") - // Find attestaitons for the release tag SHA + // Find all the attestations for the release tag SHA attestations, err := config.AttClient.GetByDigest(api.FetchParams{ Digest: releaseRefDigest.DigestWithAlg(), PredicateType: shared.ReleasePredicateType, Owner: baseRepo.RepoOwner(), Repo: baseRepo.RepoOwner() + "/" + baseRepo.RepoName(), - Limit: 10, + // TODO: Allow this value to be set via a flag. + // The limit is set to 100 to ensure we fetch all attestations for a given SHA. + // While multiple attestations can exist for a single SHA, + // only one attestation is associated with each release tag. + Limit: 100, }) if err != nil { return fmt.Errorf("no attestations for tag %s (%s)", tagName, releaseRefDigest.DigestWithAlg()) From 6bfd4bbd77061b38797c30b3982d963c5ea12169 Mon Sep 17 00:00:00 2001 From: TmINAMIII Date: Sun, 22 Jun 2025 01:57:48 +0000 Subject: [PATCH 248/249] chore: update Go version to 1.24 in devcontainer configuration --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index bc047d1c5..b76ed4fc9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,5 @@ { - "image": "mcr.microsoft.com/devcontainers/go:1.23", + "image": "mcr.microsoft.com/devcontainers/go:1.24", "features": { "ghcr.io/devcontainers/features/sshd:1": {} }, From b5867208a1d4cfd79add7899a46f253b140b9663 Mon Sep 17 00:00:00 2001 From: Andy Feller Date: Mon, 23 Jun 2025 09:58:06 -0400 Subject: [PATCH 249/249] Update missed Go 1.23 references --- .github/CONTRIBUTING.md | 2 +- docs/source.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a1ed27d99..31ef955f0 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -24,7 +24,7 @@ We accept pull requests for bug fixes and features where we've discussed the app ## Building the project Prerequisites: -- Go 1.23+ +- Go 1.24+ Build with: * Unix-like systems: `make` diff --git a/docs/source.md b/docs/source.md index 29bf51e39..e37c7679c 100644 --- a/docs/source.md +++ b/docs/source.md @@ -1,6 +1,6 @@ # Installation from source -1. Verify that you have Go 1.23+ installed +1. Verify that you have Go 1.24+ installed ```sh $ go version