Merge branch 'trunk' into 8426-add-pr-update-cmd-no-local-update

This commit is contained in:
Babak K. Shandiz 2024-06-27 22:36:36 +01:00 committed by GitHub
commit a994edda93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 406 additions and 118 deletions

View file

@ -24,3 +24,5 @@ A clear and concise description of what you expected to happen and what actually
### Logs
Paste the activity from your command line. Redact if needed.
<!-- Note: Set `GH_DEBUG=true` for verbose logs or `GH_DEBUG=api` for verbose logs with HTTP traffic details. -->

View file

@ -299,7 +299,7 @@ jobs:
rpmsign --addsign dist/*.rpm
- name: Attest release artifacts
if: inputs.environment == 'production'
uses: actions/attest-build-provenance@49df96e17e918a15956db358890b08e61c704919 # v1.2.0
uses: actions/attest-build-provenance@bdd51370e0416ac948727f861e03c2f05d32d78e # v1.3.2
with:
subject-path: "dist/gh_*"
- name: Run createrepo

10
go.mod
View file

@ -20,9 +20,9 @@ require (
github.com/gabriel-vasile/mimetype v1.4.4
github.com/gdamore/tcell/v2 v2.5.4
github.com/google/go-cmp v0.6.0
github.com/google/go-containerregistry v0.19.1
github.com/google/go-containerregistry v0.19.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/websocket v1.5.2
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-version v1.3.0
github.com/henvic/httpretty v0.1.3
@ -39,7 +39,7 @@ require (
github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc
github.com/sigstore/protobuf-specs v0.3.2
github.com/sigstore/sigstore-go v0.3.0
github.com/spf13/cobra v1.8.0
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.9.0
github.com/zalando/go-keyring v0.2.5
@ -74,7 +74,7 @@ require (
github.com/docker/distribution v2.8.2+incompatible // indirect
github.com/docker/docker v24.0.9+incompatible // indirect
github.com/docker/docker-credential-helpers v0.7.0 // indirect
github.com/fatih/color v1.14.1 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/go-chi/chi v4.1.2+incompatible // indirect
@ -98,7 +98,7 @@ require (
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/gojq v0.12.15 // indirect

27
go.sum
View file

@ -109,7 +109,6 @@ github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AX
github.com/containerd/stargz-snapshotter/estargz v0.14.3 h1:OqlDCK3ZVUO6C3B/5FSkDwbkEETK84kQgEeFwDC+62k=
github.com/containerd/stargz-snapshotter/estargz v0.14.3/go.mod h1:KY//uOCIkSuNAHhJogcZtrNHdKrA99/FCCRjE3HD36o=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
@ -141,8 +140,8 @@ github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bc
github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A=
github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@ -198,8 +197,8 @@ github.com/google/certificate-transparency-go v1.1.8 h1:LGYKkgZF7satzgTak9R4yzfJ
github.com/google/certificate-transparency-go v1.1.8/go.mod h1:bV/o8r0TBKRf1X//iiiSgWrvII4d7/8OiA+3vG26gI8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-containerregistry v0.19.1 h1:yMQ62Al6/V0Z7CqIrrS1iYoA5/oQCm88DeNujc7C1KY=
github.com/google/go-containerregistry v0.19.1/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI=
github.com/google/go-containerregistry v0.19.2 h1:TannFKE1QSajsP6hPWb5oJNgKe1IKjHukIKDUmvsV6w=
github.com/google/go-containerregistry v0.19.2/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI=
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.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
@ -218,8 +217,8 @@ github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/
github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/websocket v1.5.2 h1:qoW6V1GT3aZxybsbC6oLnailWnB+qTMVwMreOso9XUw=
github.com/gorilla/websocket v1.5.2/go.mod h1:0n9H61RBAcf5/38py2MCYbxzPIY9rOkpvvMT24Rqs30=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@ -227,13 +226,12 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c=
github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 h1:UpiO20jno/eV1eVZcxqWnUohyKRe1g8FPV/xH1s/2qs=
@ -411,8 +409,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
@ -422,7 +420,6 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

View file

@ -17,8 +17,6 @@ func NewCmdAttestation(f *cmdutil.Factory) *cobra.Command {
Short: "Work with artifact attestations",
Aliases: []string{"at"},
Long: heredoc.Doc(`
### NOTE: This feature is currently in beta, and subject to change.
Download and verify artifact attestations.
`),
}

View file

@ -25,18 +25,22 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
Args: cmdutil.ExactArgs(1, "must specify file path or container image URI, as well as one of --owner or --repo"),
Short: "Verify an artifact's integrity using attestations",
Long: heredoc.Docf(`
### NOTE: This feature is currently in beta, and subject to change.
Verify the integrity and provenance of an artifact using its associated
cryptographically signed attestations.
The command requires either:
In order to verify an attestation, you must validate the identity of the Actions
workflow that produced the attestation (a.k.a. the signer workflow). Given this
identity, the verification process checks the signatures in the attestations,
and confirms that the attestation refers to provided artifact.
To specify the artifact, the command requires:
* a file path to an artifact, or
* a container image URI (e.g. %[1]soci://<image-uri>%[1]s)
* (note that if you provide an OCI URL, you must already be authenticated with
its container registry)
In addition, the command requires either:
To fetch the attestation, and validate the identity of the signer, the command
requires either:
* the %[1]s--repo%[1]s flag (e.g. --repo github/example).
* the %[1]s--owner%[1]s flag (e.g. --owner github), or
@ -54,27 +58,38 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
To see the full results that are generated upon successful verification, i.e.
for use with a policy engine, provide the %[1]s--format=json%[1]s flag.
The attestation's certificate's Subject Alternative Name (SAN) identifies the entity
responsible for creating the attestation, which most of the time will be a GitHub
Actions workflow file located inside your repository. By default, this command uses
The signer workflow's identity is validated against the Subject Alternative Name (SAN)
within the attestation certificate. Often, the signer workflow is the
same workflow that started the run and generated the attestation, and will be
located inside your repository. For this reason, by default this command uses
either the %[1]s--repo%[1]s or the %[1]s--owner%[1]s flag value to validate the SAN.
However, if you generate attestations with a reusable workflow then the SAN will
identify the reusable workflow which may or may not be located inside your %[1]s--repo%[1]s
or %[1]s--owner%[1]s. In these situations, you can use the %[1]s--cert-identity%[1]s or
%[1]s--cert-identity-regex%[1]s flags to specify the reusable workflow's URI.
However, sometimes the caller workflow is not the same workflow that
performed the signing. If your attestation was generated via a reusable
workflow, then that reusable workflow is the signer whose identity needs to be
validated. In this situation, the signer workflow may or may not be located
inside your %[1]s--repo%[1]s or %[1]s--owner%[1]s.
When using reusable workflows, use the %[1]s--signer-repo%[1]s, %[1]s--signer-workflow%[1]s,
or %[1]s--cert-identity%[1]s flags to validate the signer workflow's identity.
For more policy verification options, see the other available flags.
`, "`"),
Example: heredoc.Doc(`
# Verify a local artifact linked with a repository
# Verify an artifact linked with a repository
$ gh attestation verify example.bin --repo github/example
# Verify a local artifact linked with an organization
# Verify an artifact linked with an organization
$ gh attestation verify example.bin --owner github
# Verify an OCI image using locally stored attestations
# Verify an artifact and output the full verification result
$ gh attestation verify example.bin --owner github --format json
# Verify an OCI image using attestations stored on disk
$ gh attestation verify oci://<image-uri> --owner github --bundle sha256:foo.jsonl
# Verify an artifact signed with a reusable workflow
$ gh attestation verify example.bin --owner github --signer-repo actions/example
`),
// PreRunE is used to validate flags before the command is run
// If an error is returned, its message will be printed to the terminal

View file

@ -21,21 +21,13 @@ type GetOptions struct {
Config func() (gh.Config, error)
BaseRepo func() (ghrepo.Interface, error)
Exporter cmdutil.Exporter
VariableName string
OrgName string
EnvName string
}
type getVariableResponse struct {
Value string `json:"value"`
// Other available but unused fields
// Name string `json:"name"`
// UpdatedAt time.Time `json:"updated_at"`
// Visibility shared.Visibility `json:"visibility"`
// SelectedReposURL string `json:"selected_repositories_url"`
// NumSelectedRepos int `json:"num_selected_repos"`
}
func NewCmdGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command {
opts := &GetOptions{
IO: f.IOStreams,
@ -72,6 +64,7 @@ func NewCmdGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command
}
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Get a variable for an organization")
cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Get a variable for an environment")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.VariableJSONFields)
return cmd
}
@ -116,8 +109,8 @@ func getRun(opts *GetOptions) error {
host, _ := cfg.Authentication().DefaultHost()
var response getVariableResponse
if err = client.REST(host, "GET", path, nil, &response); err != nil {
var variable shared.Variable
if err = client.REST(host, "GET", path, nil, &variable); err != nil {
var httpErr api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound {
return fmt.Errorf("variable %s was not found", opts.VariableName)
@ -126,7 +119,13 @@ func getRun(opts *GetOptions) error {
return fmt.Errorf("failed to get variable %s: %w", opts.VariableName, err)
}
fmt.Fprintf(opts.IO.Out, "%s\n", response.Value)
if opts.Exporter != nil {
if err := shared.PopulateSelectedRepositoryInformation(client, host, &variable); err != nil {
return err
}
return opts.Exporter.Write(opts.IO, &variable)
}
fmt.Fprintf(opts.IO.Out, "%s\n", variable.Value)
return nil
}

View file

@ -5,10 +5,12 @@ import (
"fmt"
"net/http"
"testing"
"time"
"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/variable/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
@ -53,6 +55,18 @@ func TestNewCmdGet(t *testing.T) {
cli: "-o TestOrg -e Development QUX",
wantErr: cmdutil.FlagErrorf("%s", "specify only one of `--org` or `--env`"),
},
{
name: "json",
cli: "--json name,value FOO",
wants: GetOptions{
VariableName: "FOO",
Exporter: func() cmdutil.Exporter {
exporter := cmdutil.NewJSONExporter()
exporter.SetFields([]string{"name", "value"})
return exporter
}(),
},
},
}
for _, tt := range tests {
@ -85,17 +99,21 @@ func TestNewCmdGet(t *testing.T) {
require.Equal(t, tt.wants.OrgName, gotOpts.OrgName)
require.Equal(t, tt.wants.EnvName, gotOpts.EnvName)
require.Equal(t, tt.wants.VariableName, gotOpts.VariableName)
require.Equal(t, tt.wants.Exporter, gotOpts.Exporter)
})
}
}
func Test_getRun(t *testing.T) {
tf, _ := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z")
tests := []struct {
name string
opts *GetOptions
httpStubs func(*httpmock.Registry)
wantOut string
wantErr error
name string
opts *GetOptions
httpStubs func(*httpmock.Registry)
jsonFields []string
wantOut string
wantErr error
}{
{
name: "getting repo variable",
@ -104,7 +122,7 @@ func Test_getRun(t *testing.T) {
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "repos/owner/repo/actions/variables/VARIABLE_ONE"),
httpmock.JSONResponse(getVariableResponse{
httpmock.JSONResponse(shared.Variable{
Value: "repo_var",
}))
},
@ -118,7 +136,7 @@ func Test_getRun(t *testing.T) {
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "orgs/TestOrg/actions/variables/VARIABLE_ONE"),
httpmock.JSONResponse(getVariableResponse{
httpmock.JSONResponse(shared.Variable{
Value: "org_var",
}))
},
@ -132,12 +150,44 @@ func Test_getRun(t *testing.T) {
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "repos/owner/repo/environments/Development/variables/VARIABLE_ONE"),
httpmock.JSONResponse(getVariableResponse{
httpmock.JSONResponse(shared.Variable{
Value: "env_var",
}))
},
wantOut: "env_var\n",
},
{
name: "json",
opts: &GetOptions{
VariableName: "VARIABLE_ONE",
},
jsonFields: []string{"name", "value", "visibility", "updatedAt", "createdAt", "numSelectedRepos", "selectedReposURL"},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "repos/owner/repo/actions/variables/VARIABLE_ONE"),
httpmock.JSONResponse(shared.Variable{
Name: "VARIABLE_ONE",
Value: "repo_var",
UpdatedAt: tf,
CreatedAt: tf,
Visibility: shared.Organization,
SelectedReposURL: "path/to/fetch/selected/repos",
NumSelectedRepos: 0, // This should be populated in a second API call.
}))
reg.Register(httpmock.REST("GET", "path/to/fetch/selected/repos"),
httpmock.JSONResponse(map[string]interface{}{
"total_count": 99,
}))
},
wantOut: `{
"name": "VARIABLE_ONE",
"value": "repo_var",
"updatedAt": "2024-01-01T00:00:00Z",
"createdAt": "2024-01-01T00:00:00Z",
"visibility": "organization",
"selectedReposURL": "path/to/fetch/selected/repos",
"numSelectedRepos": 99
}`,
},
{
name: "when the variable is not found, an error is returned",
opts: &GetOptions{
@ -185,6 +235,12 @@ func Test_getRun(t *testing.T) {
return config.NewBlankConfig(), nil
}
if tt.jsonFields != nil {
exporter := cmdutil.NewJSONExporter()
exporter.SetFields(tt.jsonFields)
tt.opts.Exporter = exporter
}
err := getRun(tt.opts)
if err != nil {
require.EqualError(t, tt.wantErr, err.Error())
@ -192,7 +248,11 @@ func Test_getRun(t *testing.T) {
}
require.NoError(t, err)
require.Equal(t, tt.wantOut, stdout.String())
if tt.jsonFields != nil {
require.JSONEq(t, tt.wantOut, stdout.String())
} else {
require.Equal(t, tt.wantOut, stdout.String())
}
}
}
@ -200,3 +260,23 @@ func Test_getRun(t *testing.T) {
t.Run(tt.name+" no-tty", runTest(false))
}
}
func TestExportVariables(t *testing.T) {
ios, _, stdout, _ := iostreams.Test()
tf, _ := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z")
vs := &shared.Variable{
Name: "v1",
Value: "test1",
UpdatedAt: tf,
CreatedAt: tf,
Visibility: shared.All,
SelectedReposURL: "https://someurl.com",
NumSelectedRepos: 1,
}
exporter := cmdutil.NewJSONExporter()
exporter.SetFields(shared.VariableJSONFields)
require.NoError(t, exporter.Write(ios, vs))
require.JSONEq(t,
`{"name":"v1","numSelectedRepos":1,"selectedReposURL":"https://someurl.com","updatedAt":"2024-01-01T00:00:00Z","createdAt":"2024-01-01T00:00:00Z","value":"test1","visibility":"all"}`,
stdout.String())
}

View file

@ -3,6 +3,7 @@ package list
import (
"fmt"
"net/http"
"slices"
"strings"
"time"
@ -30,14 +31,7 @@ type ListOptions struct {
EnvName string
}
var variableFields = []string{
"name",
"value",
"visibility",
"updatedAt",
"numSelectedRepos",
"selectedReposURL",
}
const fieldNumSelectedRepos = "numSelectedRepos"
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
opts := &ListOptions{
@ -76,7 +70,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List variables for an organization")
cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "List variables for an environment")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, variableFields)
cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.VariableJSONFields)
return cmd
}
@ -103,9 +97,21 @@ func listRun(opts *ListOptions) error {
return err
}
var variables []Variable
// Since populating the `NumSelectedRepos` field costs further API requests
// (one per secret), it's important to avoid extra calls when the output will
// not present the field's value. So, we should only populate this field in
// these cases:
// 1. The command is run in the TTY mode without the `--json <fields>` option.
// 2. The command is run with `--json <fields>` option, and `numSelectedRepos`
// is among the selected fields. In this case, TTY mode is irrelevant.
showSelectedRepoInfo := opts.IO.IsStdoutTTY()
if opts.Exporter != nil {
// Note that if there's an exporter set, then we don't mind the TTY mode
// because we just have to populate the requested fields.
showSelectedRepoInfo = slices.Contains(opts.Exporter.Fields(), fieldNumSelectedRepos)
}
var variables []shared.Variable
switch variableEntity {
case shared.Repository:
variables, err = getRepoVariables(client, baseRepo)
@ -170,20 +176,7 @@ func listRun(opts *ListOptions) error {
return nil
}
type Variable struct {
Name string `json:"name"`
Value string `json:"value"`
UpdatedAt time.Time `json:"updated_at"`
Visibility shared.Visibility `json:"visibility"`
SelectedReposURL string `json:"selected_repositories_url"`
NumSelectedRepos int `json:"num_selected_repos"`
}
func (v *Variable) ExportData(fields []string) map[string]interface{} {
return cmdutil.StructExportData(v, fields)
}
func fmtVisibility(s Variable) string {
func fmtVisibility(s shared.Variable) string {
switch s.Visibility {
case shared.All:
return "Visible to all repositories"
@ -199,22 +192,23 @@ func fmtVisibility(s Variable) string {
return ""
}
func getRepoVariables(client *http.Client, repo ghrepo.Interface) ([]Variable, error) {
func getRepoVariables(client *http.Client, repo ghrepo.Interface) ([]shared.Variable, error) {
return getVariables(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/variables", ghrepo.FullName(repo)))
}
func getEnvVariables(client *http.Client, repo ghrepo.Interface, envName string) ([]Variable, error) {
func getEnvVariables(client *http.Client, repo ghrepo.Interface, envName string) ([]shared.Variable, error) {
path := fmt.Sprintf("repos/%s/environments/%s/variables", ghrepo.FullName(repo), envName)
return getVariables(client, repo.RepoHost(), path)
}
func getOrgVariables(client *http.Client, host, orgName string, showSelectedRepoInfo bool) ([]Variable, error) {
func getOrgVariables(client *http.Client, host, orgName string, showSelectedRepoInfo bool) ([]shared.Variable, error) {
variables, err := getVariables(client, host, fmt.Sprintf("orgs/%s/actions/variables", orgName))
if err != nil {
return nil, err
}
apiClient := api.NewClientFromHTTP(client)
if showSelectedRepoInfo {
err = populateSelectedRepositoryInformation(client, host, variables)
err = shared.PopulateMultipleSelectedRepositoryInformation(apiClient, host, variables)
if err != nil {
return nil, err
}
@ -222,13 +216,13 @@ func getOrgVariables(client *http.Client, host, orgName string, showSelectedRepo
return variables, nil
}
func getVariables(client *http.Client, host, path string) ([]Variable, error) {
var results []Variable
func getVariables(client *http.Client, host, path string) ([]shared.Variable, error) {
var results []shared.Variable
apiClient := api.NewClientFromHTTP(client)
path = fmt.Sprintf("%s?per_page=100", path)
for path != "" {
response := struct {
Variables []Variable
Variables []shared.Variable
}{}
var err error
path, err = apiClient.RESTWithNext(host, "GET", path, nil, &response)
@ -239,20 +233,3 @@ func getVariables(client *http.Client, host, path string) ([]Variable, error) {
}
return results, nil
}
func populateSelectedRepositoryInformation(client *http.Client, host string, variables []Variable) error {
apiClient := api.NewClientFromHTTP(client)
for i, variable := range variables {
if variable.SelectedReposURL == "" {
continue
}
response := struct {
TotalCount int `json:"total_count"`
}{}
if err := apiClient.REST(host, "GET", variable.SelectedReposURL, nil, &response); err != nil {
return fmt.Errorf("failed determining selected repositories for %s: %w", variable.Name, err)
}
variables[i].NumSelectedRepos = response.TotalCount
}
return nil
}

View file

@ -81,10 +81,11 @@ func TestNewCmdList(t *testing.T) {
func Test_listRun(t *testing.T) {
tests := []struct {
name string
tty bool
opts *ListOptions
wantOut []string
name string
tty bool
opts *ListOptions
jsonFields []string
wantOut []string
}{
{
name: "repo tty",
@ -107,6 +108,17 @@ func Test_listRun(t *testing.T) {
"VARIABLE_THREE\tthree\t1975-11-30T00:00:00Z",
},
},
{
name: "repo not tty, json",
tty: false,
opts: &ListOptions{},
jsonFields: []string{"name", "value"},
wantOut: []string{`[
{"name":"VARIABLE_ONE","value":"one"},
{"name":"VARIABLE_TWO","value":"two"},
{"name":"VARIABLE_THREE","value":"three"}
]`},
},
{
name: "org tty",
tty: true,
@ -132,6 +144,19 @@ func Test_listRun(t *testing.T) {
"VARIABLE_THREE\torg_three\t1975-11-30T00:00:00Z\tSELECTED",
},
},
{
name: "org not tty, json",
tty: false,
opts: &ListOptions{
OrgName: "UmbrellaCorporation",
},
jsonFields: []string{"name", "value"},
wantOut: []string{`[
{"name":"VARIABLE_ONE","value":"org_one"},
{"name":"VARIABLE_TWO","value":"org_two"},
{"name":"VARIABLE_THREE","value":"org_three"}
]`},
},
{
name: "env tty",
tty: true,
@ -157,6 +182,19 @@ func Test_listRun(t *testing.T) {
"VARIABLE_THREE\tthree\t1975-11-30T00:00:00Z",
},
},
{
name: "env not tty, json",
tty: false,
opts: &ListOptions{
EnvName: "Development",
},
jsonFields: []string{"name", "value"},
wantOut: []string{`[
{"name":"VARIABLE_ONE","value":"one"},
{"name":"VARIABLE_TWO","value":"two"},
{"name":"VARIABLE_THREE","value":"three"}
]`},
},
}
for _, tt := range tests {
@ -175,9 +213,9 @@ func Test_listRun(t *testing.T) {
t1, _ := time.Parse("2006-01-02", "2020-12-04")
t2, _ := time.Parse("2006-01-02", "1975-11-30")
payload := struct {
Variables []Variable
Variables []shared.Variable
}{
Variables: []Variable{
Variables: []shared.Variable{
{
Name: "VARIABLE_ONE",
Value: "one",
@ -196,7 +234,7 @@ func Test_listRun(t *testing.T) {
},
}
if tt.opts.OrgName != "" {
payload.Variables = []Variable{
payload.Variables = []shared.Variable{
{
Name: "VARIABLE_ONE",
Value: "org_one",
@ -247,11 +285,138 @@ func Test_listRun(t *testing.T) {
return t
}
if tt.jsonFields != nil {
jsonExporter := cmdutil.NewJSONExporter()
jsonExporter.SetFields(tt.jsonFields)
tt.opts.Exporter = jsonExporter
}
err := listRun(tt.opts)
assert.NoError(t, err)
expected := fmt.Sprintf("%s\n", strings.Join(tt.wantOut, "\n"))
assert.Equal(t, expected, stdout.String())
if tt.jsonFields != nil {
assert.JSONEq(t, expected, stdout.String())
} else {
assert.Equal(t, expected, stdout.String())
}
})
}
}
// Test_listRun_populatesNumSelectedReposIfRequired asserts that NumSelectedRepos
// field is populated **only** when it's going to be presented in the output. Since
// populating this field costs further API requests (one per variable), it's important
// to avoid extra calls when the output will not present the field's value. Note
// that NumSelectedRepos is only meant for organization variables.
//
// We should only populate the NumSelectedRepos field in these cases:
// 1. The command is run in the TTY mode without the `--json <fields>` option.
// 2. The command is run with `--json <fields>` option, and `numSelectedRepos`
// is among the selected fields. In this case, TTY mode is irrelevant.
func Test_listRun_populatesNumSelectedReposIfRequired(t *testing.T) {
tests := []struct {
name string
tty bool
jsonFields []string
wantPopulated bool
}{
{
name: "org tty",
tty: true,
wantPopulated: true,
},
{
name: "org tty, json with numSelectedRepos",
tty: true,
jsonFields: []string{"numSelectedRepos"},
wantPopulated: true,
},
{
name: "org tty, json without numSelectedRepos",
tty: true,
jsonFields: []string{"name"},
wantPopulated: false,
},
{
name: "org not tty",
tty: false,
wantPopulated: false,
},
{
name: "org not tty, json with numSelectedRepos",
tty: false,
jsonFields: []string{"numSelectedRepos"},
wantPopulated: true,
},
{
name: "org not tty, json without numSelectedRepos",
tty: false,
jsonFields: []string{"name"},
wantPopulated: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
reg.Verify(t)
reg.Register(
httpmock.REST("GET", "orgs/umbrellaOrganization/actions/variables"),
httpmock.JSONResponse(struct{ Variables []shared.Variable }{
[]shared.Variable{
{
Name: "VARIABLE",
Visibility: shared.Selected,
SelectedReposURL: "https://api.github.com/orgs/umbrellaOrganization/actions/variables/VARIABLE/repositories",
},
},
}))
reg.Register(
httpmock.REST("GET", "orgs/umbrellaOrganization/actions/variables/VARIABLE/repositories"),
httpmock.JSONResponse(struct {
TotalCount int `json:"total_count"`
}{999}))
opts := &ListOptions{
OrgName: "umbrellaOrganization",
}
if tt.jsonFields != nil {
exporter := cmdutil.NewJSONExporter()
exporter.SetFields(tt.jsonFields)
opts.Exporter = exporter
}
ios, _, _, _ := iostreams.Test()
ios.SetStdoutTTY(tt.tty)
opts.IO = ios
opts.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("owner/repo")
}
opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
opts.Config = func() (gh.Config, error) {
return config.NewBlankConfig(), nil
}
opts.Now = func() time.Time {
t, _ := time.Parse(time.RFC822, "4 Apr 24 00:00 UTC")
return t
}
require.NoError(t, listRun(opts))
if tt.wantPopulated {
// There should be 2 requests; one to get the variables list and
// another to populate the numSelectedRepos field.
assert.Len(t, reg.Requests, 2)
} else {
// Only one requests to get the variables list.
assert.Len(t, reg.Requests, 1)
}
})
}
}
@ -279,18 +444,19 @@ func Test_getVariables_pagination(t *testing.T) {
func TestExportVariables(t *testing.T) {
ios, _, stdout, _ := iostreams.Test()
tf, _ := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z")
vs := []Variable{{
vs := []shared.Variable{{
Name: "v1",
Value: "test1",
UpdatedAt: tf,
CreatedAt: tf,
Visibility: shared.All,
SelectedReposURL: "https://someurl.com",
NumSelectedRepos: 1,
}}
exporter := cmdutil.NewJSONExporter()
exporter.SetFields(variableFields)
exporter.SetFields(shared.VariableJSONFields)
require.NoError(t, exporter.Write(ios, vs))
require.JSONEq(t,
`[{"name":"v1","numSelectedRepos":1,"selectedReposURL":"https://someurl.com","updatedAt":"2024-01-01T00:00:00Z","value":"test1","visibility":"all"}]`,
`[{"name":"v1","numSelectedRepos":1,"selectedReposURL":"https://someurl.com","updatedAt":"2024-01-01T00:00:00Z","createdAt":"2024-01-01T00:00:00Z","value":"test1","visibility":"all"}]`,
stdout.String())
}

View file

@ -2,6 +2,11 @@ package shared
import (
"errors"
"fmt"
"time"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/pkg/cmdutil"
)
type Visibility string
@ -20,6 +25,30 @@ const (
Environment = "environment"
)
type Variable struct {
Name string `json:"name"`
Value string `json:"value"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
Visibility Visibility `json:"visibility"`
SelectedReposURL string `json:"selected_repositories_url"`
NumSelectedRepos int `json:"num_selected_repos"`
}
var VariableJSONFields = []string{
"name",
"value",
"visibility",
"updatedAt",
"createdAt",
"numSelectedRepos",
"selectedReposURL",
}
func (v *Variable) ExportData(fields []string) map[string]interface{} {
return cmdutil.StructExportData(v, fields)
}
func GetVariableEntity(orgName, envName string) (VariableEntity, error) {
orgSet := orgName != ""
envSet := envName != ""
@ -36,3 +65,28 @@ func GetVariableEntity(orgName, envName string) (VariableEntity, error) {
}
return Repository, nil
}
func PopulateMultipleSelectedRepositoryInformation(apiClient *api.Client, host string, variables []Variable) error {
for i, variable := range variables {
if err := PopulateSelectedRepositoryInformation(apiClient, host, &variable); err != nil {
return err
}
variables[i] = variable
}
return nil
}
func PopulateSelectedRepositoryInformation(apiClient *api.Client, host string, variable *Variable) error {
if variable.SelectedReposURL == "" {
return nil
}
response := struct {
TotalCount int `json:"total_count"`
}{}
if err := apiClient.REST(host, "GET", variable.SelectedReposURL, nil, &response); err != nil {
return fmt.Errorf("failed determining selected repositories for %s: %w", variable.Name, err)
}
variable.NumSelectedRepos = response.TotalCount
return nil
}