diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 31cd4b8e7..efc729711 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -33,7 +33,7 @@ jobs: steps: - name: Validate tag name format run: | - if [[ ! "${{ inputs.tag_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$]]; then + if [[ ! "${{ inputs.tag_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Invalid tag name format. Must be in the form v1.2.3" exit 1 fi diff --git a/go.mod b/go.mod index 501118174..38837b883 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-version v1.3.0 github.com/henvic/httpretty v0.1.4 - github.com/in-toto/attestation v1.1.0 + github.com/in-toto/attestation v1.1.1 github.com/joho/godotenv v1.5.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/mattn/go-colorable v0.1.14 diff --git a/go.sum b/go.sum index fea9b2418..883ac72ce 100644 --- a/go.sum +++ b/go.sum @@ -266,8 +266,8 @@ github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= -github.com/in-toto/attestation v1.1.0 h1:oRWzfmZPDSctChD0VaQV7MJrywKOzyNrtpENQFq//2Q= -github.com/in-toto/attestation v1.1.0/go.mod h1:DB59ytd3z7cIHgXxwpSX2SABrU6WJUKg/grpdgHVgVs= +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/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= diff --git a/pkg/cmd/attestation/api/client.go b/pkg/cmd/attestation/api/client.go index 37579b7bc..6054bc98e 100644 --- a/pkg/cmd/attestation/api/client.go +++ b/pkg/cmd/attestation/api/client.go @@ -176,23 +176,25 @@ func (c *LiveClient) fetchBundleFromAttestations(attestations []*Attestation) ([ return fmt.Errorf("attestation has no bundle or bundle URL") } - // for now, we fallback to the bundle field if the bundle URL is empty - if a.BundleURL == "" { - c.logger.VerbosePrintf("Bundle URL is empty. Falling back to bundle field\n\n") + // If the bundle field is nil, try to fetch the bundle with the provided URL + if a.Bundle == nil { + c.logger.VerbosePrintf("Bundle field is empty. Trying to fetch with bundle URL\n\n") + b, err := c.GetBundle(a.BundleURL) + if err != nil { + return fmt.Errorf("failed to fetch bundle with URL: %w", err) + } fetched[i] = &Attestation{ - Bundle: a.Bundle, + Bundle: b, } return nil } - // otherwise fetch the bundle with the provided URL - b, err := c.GetBundle(a.BundleURL) - if err != nil { - return fmt.Errorf("failed to fetch bundle with URL: %w", err) - } + // otherwise fall back to the bundle field + c.logger.VerbosePrintf("Fetching bundle from Bundle field\n\n") fetched[i] = &Attestation{ - Bundle: b, + Bundle: a.Bundle, } + return nil }) } diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go index 65f8d59ca..3d180af8f 100644 --- a/pkg/cmd/attestation/api/client_test.go +++ b/pkg/cmd/attestation/api/client_test.go @@ -180,7 +180,7 @@ func TestGetByDigest_Error(t *testing.T) { require.Nil(t, attestations) } -func TestFetchBundleFromAttestations(t *testing.T) { +func TestFetchBundleFromAttestations_BundleURL(t *testing.T) { httpClient := &mockHttpClient{} client := LiveClient{ httpClient: httpClient, @@ -188,12 +188,15 @@ func TestFetchBundleFromAttestations(t *testing.T) { } att1 := makeTestAttestation() + att1.Bundle = nil att2 := makeTestAttestation() + att2.Bundle = nil + // zero out the bundle field so it tries fetching by URL attestations := []*Attestation{&att1, &att2} fetched, err := client.fetchBundleFromAttestations(attestations) require.NoError(t, err) require.Len(t, fetched, 2) - require.Equal(t, "application/vnd.dev.sigstore.bundle.v0.3+json", fetched[0].Bundle.GetMediaType()) + require.NotNil(t, "application/vnd.dev.sigstore.bundle.v0.3+json", fetched[0].Bundle.GetMediaType()) httpClient.AssertNumberOfCalls(t, "OnGetSuccess", 2) } @@ -211,7 +214,7 @@ func TestFetchBundleFromAttestations_InvalidAttestation(t *testing.T) { require.Nil(t, fetched, 2) } -func TestFetchBundleFromAttestations_Fail(t *testing.T) { +func TestFetchBundleFromAttestations_Fail_BundleURL(t *testing.T) { httpClient := &failAfterOneCallHttpClient{} c := &LiveClient{ @@ -220,7 +223,10 @@ func TestFetchBundleFromAttestations_Fail(t *testing.T) { } att1 := makeTestAttestation() + att1.Bundle = nil att2 := makeTestAttestation() + att2.Bundle = nil + // zero out the bundle field so it tries fetching by URL attestations := []*Attestation{&att1, &att2} fetched, err := c.fetchBundleFromAttestations(attestations) require.Error(t, err) @@ -237,6 +243,7 @@ func TestFetchBundleFromAttestations_FetchByURLFail(t *testing.T) { } a := makeTestAttestation() + a.Bundle = nil attestations := []*Attestation{&a} bundle, err := c.fetchBundleFromAttestations(attestations) require.Error(t, err) diff --git a/pkg/cmd/repo/autolink/autolink.go b/pkg/cmd/repo/autolink/autolink.go index 1fd218354..09b766b85 100644 --- a/pkg/cmd/repo/autolink/autolink.go +++ b/pkg/cmd/repo/autolink/autolink.go @@ -4,6 +4,7 @@ import ( "github.com/MakeNowJust/heredoc" cmdCreate "github.com/cli/cli/v2/pkg/cmd/repo/autolink/create" cmdList "github.com/cli/cli/v2/pkg/cmd/repo/autolink/list" + cmdView "github.com/cli/cli/v2/pkg/cmd/repo/autolink/view" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -24,6 +25,7 @@ func NewCmdAutolink(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdList.NewCmdList(f, nil)) cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, nil)) return cmd } diff --git a/pkg/cmd/repo/autolink/create/create.go b/pkg/cmd/repo/autolink/create/create.go index ac3035c79..aad71a941 100644 --- a/pkg/cmd/repo/autolink/create/create.go +++ b/pkg/cmd/repo/autolink/create/create.go @@ -109,9 +109,9 @@ func createRun(opts *createOptions) error { cs := opts.IO.ColorScheme() fmt.Fprintf(opts.IO.Out, - "%s Created repository autolink %d on %s\n", + "%s Created repository autolink %s on %s\n", cs.SuccessIconWithColor(cs.Green), - autolink.ID, + cs.Cyanf("%d", autolink.ID), ghrepo.FullName(repo)) return nil diff --git a/pkg/cmd/repo/autolink/list/http_test.go b/pkg/cmd/repo/autolink/list/http_test.go index 0fa918f61..65289c419 100644 --- a/pkg/cmd/repo/autolink/list/http_test.go +++ b/pkg/cmd/repo/autolink/list/http_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestAutoLinkLister_List(t *testing.T) { +func TestAutolinkLister_List(t *testing.T) { tests := []struct { name string repo ghrepo.Interface diff --git a/pkg/cmd/repo/autolink/list/list.go b/pkg/cmd/repo/autolink/list/list.go index a68c03f3c..402479c44 100644 --- a/pkg/cmd/repo/autolink/list/list.go +++ b/pkg/cmd/repo/autolink/list/list.go @@ -15,13 +15,6 @@ import ( "github.com/spf13/cobra" ) -var autolinkFields = []string{ - "id", - "isAlphanumeric", - "keyPrefix", - "urlTemplate", -} - type listOptions struct { BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser @@ -70,7 +63,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*listOptions) error) *cobra.Comman } cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "List autolink references in the web browser") - cmdutil.AddJSONFlags(cmd, &opts.Exporter, autolinkFields) + cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.AutolinkFields) return cmd } @@ -111,8 +104,10 @@ func listRun(opts *listOptions) error { tp := tableprinter.New(opts.IO, tableprinter.WithHeader("ID", "KEY PREFIX", "URL TEMPLATE", "ALPHANUMERIC")) + cs := opts.IO.ColorScheme() + for _, autolink := range autolinks { - tp.AddField(fmt.Sprintf("%d", autolink.ID)) + tp.AddField(cs.Cyanf("%d", autolink.ID)) tp.AddField(autolink.KeyPrefix) tp.AddField(autolink.URLTemplate) tp.AddField(strconv.FormatBool(autolink.IsAlphanumeric)) diff --git a/pkg/cmd/repo/autolink/shared/autolink.go b/pkg/cmd/repo/autolink/shared/autolink.go index d79537c31..28e975e7c 100644 --- a/pkg/cmd/repo/autolink/shared/autolink.go +++ b/pkg/cmd/repo/autolink/shared/autolink.go @@ -9,6 +9,13 @@ type Autolink struct { URLTemplate string `json:"url_template"` } +var AutolinkFields = []string{ + "id", + "isAlphanumeric", + "keyPrefix", + "urlTemplate", +} + func (a *Autolink) ExportData(fields []string) map[string]interface{} { return cmdutil.StructExportData(a, fields) } diff --git a/pkg/cmd/repo/autolink/view/http.go b/pkg/cmd/repo/autolink/view/http.go new file mode 100644 index 000000000..8dd6dc12d --- /dev/null +++ b/pkg/cmd/repo/autolink/view/http.go @@ -0,0 +1,46 @@ +package view + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" +) + +type AutolinkViewer struct { + HTTPClient *http.Client +} + +func (a *AutolinkViewer) View(repo ghrepo.Interface, id string) (*shared.Autolink, error) { + path := fmt.Sprintf("repos/%s/%s/autolinks/%s", repo.RepoOwner(), repo.RepoName(), id) + url := ghinstance.RESTPrefix(repo.RepoHost()) + path + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := a.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("HTTP 404: Either no autolink with this ID exists for this repository or you are missing admin rights to the repository. (https://api.github.com/%s)", path) + } else if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + var autolink shared.Autolink + err = json.NewDecoder(resp.Body).Decode(&autolink) + + if err != nil { + return nil, err + } + + return &autolink, nil +} diff --git a/pkg/cmd/repo/autolink/view/http_test.go b/pkg/cmd/repo/autolink/view/http_test.go new file mode 100644 index 000000000..5bfe9369f --- /dev/null +++ b/pkg/cmd/repo/autolink/view/http_test.go @@ -0,0 +1,102 @@ +package view + +import ( + "fmt" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAutolinkViewer_View(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + id string + stubStatus int + stubRespJSON string + + expectedAutolink *shared.Autolink + expectErr bool + expectedErrMsg string + }{ + { + name: "200 successful alphanumeric view", + id: "123", + stubStatus: 200, + stubRespJSON: `{ + "id": 123, + "key_prefix": "TICKET-", + "url_template": "https://example.com/TICKET?query=", + "is_alphanumeric": true + }`, + expectedAutolink: &shared.Autolink{ + ID: 123, + KeyPrefix: "TICKET-", + URLTemplate: "https://example.com/TICKET?query=", + IsAlphanumeric: true, + }, + }, + { + name: "200 successful numeric view", + id: "123", + stubStatus: 200, + stubRespJSON: `{ + "id": 123, + "key_prefix": "TICKET-", + "url_template": "https://example.com/TICKET?query=", + "is_alphanumeric": false + }`, + expectedAutolink: &shared.Autolink{ + ID: 123, + KeyPrefix: "TICKET-", + URLTemplate: "https://example.com/TICKET?query=", + IsAlphanumeric: false, + }, + }, + { + name: "404 repo or autolink not found", + id: "123", + stubStatus: 404, + stubRespJSON: `{ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest/repos/autolinks#get-an-autolink-reference-of-a-repository", + "status": "404" + }`, + expectErr: true, + expectedErrMsg: "HTTP 404: Either no autolink with this ID exists for this repository or you are missing admin rights to the repository. (https://api.github.com/repos/OWNER/REPO/autolinks/123)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST( + http.MethodGet, + fmt.Sprintf("repos/%s/%s/autolinks/%s", repo.RepoOwner(), repo.RepoName(), tt.id), + ), + httpmock.StatusStringResponse(tt.stubStatus, tt.stubRespJSON), + ) + defer reg.Verify(t) + + autolinkCreator := &AutolinkViewer{ + HTTPClient: &http.Client{Transport: reg}, + } + + autolink, err := autolinkCreator.View(repo, tt.id) + + if tt.expectErr { + require.EqualError(t, err, tt.expectedErrMsg) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedAutolink, autolink) + } + }) + } +} diff --git a/pkg/cmd/repo/autolink/view/view.go b/pkg/cmd/repo/autolink/view/view.go new file mode 100644 index 000000000..0b212aece --- /dev/null +++ b/pkg/cmd/repo/autolink/view/view.go @@ -0,0 +1,96 @@ +package view + +import ( + "fmt" + + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type viewOptions struct { + BaseRepo func() (ghrepo.Interface, error) + Browser browser.Browser + AutolinkClient AutolinkViewClient + IO *iostreams.IOStreams + Exporter cmdutil.Exporter + + ID string +} + +type AutolinkViewClient interface { + View(repo ghrepo.Interface, id string) (*shared.Autolink, error) +} + +func NewCmdView(f *cmdutil.Factory, runF func(*viewOptions) error) *cobra.Command { + opts := &viewOptions{ + Browser: f.Browser, + IO: f.IOStreams, + } + + cmd := &cobra.Command{ + Use: "view ", + Short: "View an autolink reference", + Long: "View an autolink reference for a repository.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + httpClient, err := f.HttpClient() + if err != nil { + return err + } + + opts.BaseRepo = f.BaseRepo + opts.ID = args[0] + opts.AutolinkClient = &AutolinkViewer{HTTPClient: httpClient} + + if runF != nil { + return runF(opts) + } + + return viewRun(opts) + }, + } + + cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.AutolinkFields) + + return cmd +} + +func viewRun(opts *viewOptions) error { + repo, err := opts.BaseRepo() + if err != nil { + return err + } + + out := opts.IO.Out + cs := opts.IO.ColorScheme() + + autolink, err := opts.AutolinkClient.View(repo, opts.ID) + + if err != nil { + return fmt.Errorf("%s %w", cs.Red("error viewing autolink:"), err) + } + + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, autolink) + } + + fmt.Fprintf(out, "Autolink in %s\n\n", ghrepo.FullName(repo)) + + fmt.Fprint(out, cs.Bold("ID: ")) + fmt.Fprintln(out, cs.Cyanf("%d", autolink.ID)) + + fmt.Fprint(out, cs.Bold("Key Prefix: ")) + fmt.Fprintln(out, autolink.KeyPrefix) + + fmt.Fprint(out, cs.Bold("URL Template: ")) + fmt.Fprintln(out, autolink.URLTemplate) + + fmt.Fprint(out, cs.Bold("Alphanumeric: ")) + fmt.Fprintln(out, autolink.IsAlphanumeric) + + return nil +} diff --git a/pkg/cmd/repo/autolink/view/view_test.go b/pkg/cmd/repo/autolink/view/view_test.go new file mode 100644 index 000000000..bc0a1bf7f --- /dev/null +++ b/pkg/cmd/repo/autolink/view/view_test.go @@ -0,0 +1,197 @@ +package view + +import ( + "bytes" + "net/http" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/jsonfieldstest" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJSONFields(t *testing.T) { + jsonfieldstest.ExpectCommandToSupportJSONFields(t, NewCmdView, []string{ + "id", + "isAlphanumeric", + "keyPrefix", + "urlTemplate", + }) +} + +func TestNewCmdView(t *testing.T) { + tests := []struct { + name string + input string + output viewOptions + wantErr bool + wantExporter bool + errMsg string + }{ + { + name: "no argument", + input: "", + wantErr: true, + errMsg: "accepts 1 arg(s), received 0", + }, + { + name: "id provided", + input: "123", + output: viewOptions{ID: "123"}, + }, + { + name: "json flag", + input: "123 --json id", + output: viewOptions{}, + wantExporter: true, + }, + { + name: "invalid json flag", + input: "123 --json invalid", + output: viewOptions{}, + wantErr: true, + errMsg: "Unknown JSON field: \"invalid\"\nAvailable fields:\n id\n isAlphanumeric\n keyPrefix\n urlTemplate", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + } + f.HttpClient = func() (*http.Client, error) { + return &http.Client{}, nil + } + + argv, err := shlex.Split(tt.input) + require.NoError(t, err) + + var gotOpts *viewOptions + cmd := NewCmdView(f, func(opts *viewOptions) 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 { + require.EqualError(t, err, tt.errMsg) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantExporter, gotOpts.Exporter != nil) + } + }) + } +} + +type stubAutoLinkViewer struct { + autolink *shared.Autolink + err error +} + +func (g stubAutoLinkViewer) View(repo ghrepo.Interface, id string) (*shared.Autolink, error) { + return g.autolink, g.err +} + +type testAutolinkClientViewError struct{} + +func (e testAutolinkClientViewError) Error() string { + return "autolink client view error" +} + +func TestViewRun(t *testing.T) { + tests := []struct { + name string + opts *viewOptions + stubViewer stubAutoLinkViewer + expectedErr error + wantStdout string + }{ + { + name: "view", + opts: &viewOptions{ + ID: "1", + }, + stubViewer: stubAutoLinkViewer{ + autolink: &shared.Autolink{ + ID: 1, + KeyPrefix: "TICKET-", + URLTemplate: "https://example.com/TICKET?query=", + IsAlphanumeric: true, + }, + }, + wantStdout: heredoc.Doc(` + Autolink in OWNER/REPO + + ID: 1 + Key Prefix: TICKET- + URL Template: https://example.com/TICKET?query= + Alphanumeric: true + `), + }, + { + name: "view json", + opts: &viewOptions{ + Exporter: func() cmdutil.Exporter { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"id"}) + return exporter + }(), + }, + stubViewer: stubAutoLinkViewer{ + autolink: &shared.Autolink{ + ID: 1, + KeyPrefix: "TICKET-", + URLTemplate: "https://example.com/TICKET?query=", + IsAlphanumeric: true, + }, + }, + wantStdout: "{\"id\":1}\n", + }, + { + name: "client error", + opts: &viewOptions{}, + stubViewer: stubAutoLinkViewer{ + autolink: nil, + err: testAutolinkClientViewError{}, + }, + expectedErr: testAutolinkClientViewError{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + + opts := tt.opts + opts.IO = ios + opts.Browser = &browser.Stub{} + + opts.IO = ios + opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } + + opts.AutolinkClient = &tt.stubViewer + err := viewRun(opts) + + if tt.expectedErr != nil { + require.Error(t, err) + assert.ErrorIs(t, err, tt.expectedErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantStdout, stdout.String()) + } + }) + } +}