diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 9a0acb58e..40d0a83e3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -4,23 +4,22 @@ Hi! Thanks for your interest in contributing to the GitHub CLI! We accept pull requests for bug fixes and features where we've discussed the approach in an issue and given the go-ahead for a community member to work on it. We'd also love to hear about ideas for new features as issues. -Please do: +### Please do: -* Check issues to verify that a [bug][bug issues] or [feature request][feature request issues] issue does not already exist for the same problem or feature. -* Open an issue if things aren't working as expected. -* Open an issue to propose a significant change. -* Open an issue to propose a design for an issue labelled [`needs-design` and `help wanted`][needs design and help wanted], following the [proposing a design guidelines](#proposing-a-design) instructions below. -* Open a pull request to fix a bug. -* Open a pull request to fix documentation about a command. -* Open a pull request for any issue labelled [`help wanted`][hw] or [`good first issue`][gfi]. +* Check issues to verify that a [bug][bug issues] or [feature request][feature request issues] issue does not already exist for the same problem or feature +* Open an issue if things aren't working as expected +* Open an issue to propose a significant change +* Open an issue to propose a design for an issue labelled [`needs-design` and `help wanted`][needs design and help wanted], following the [proposing a design guidelines](#proposing-a-design) instructions below +* Mention `@cli/code-reviewers` when an issue you want to work on does not have clear Acceptance Criteria +* Open a pull request for any issue labelled [`help wanted`][hw] and [`good first issue`][gfi] -Please avoid: +### Please _do not_: -* Opening pull requests for issues marked `needs-design`, `needs-investigation`, or `blocked`. -* Opening pull requests that haven't been approved for work in an issue -* Adding installation instructions specifically for your OS/package manager. -* Opening pull requests for any issue marked `core`. These issues require additional context from - the core CLI team at GitHub and any external pull requests will not be accepted. +* Open a pull request for issues without the `help wanted` label or explicit Acceptance Criteria +* Expand pull request scope to include changes that are not described in the issue's Acceptance Criteria +* Add installation instructions specifically for your OS/package manager +* Open pull requests for any issue marked `core`. These issues require additional context from + the core CLI team at GitHub and any external pull requests will not be accepted ## Building the project diff --git a/docs/releasing.md b/docs/releasing.md index 4b977efdd..5e3e48a98 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,39 +1,52 @@ # Releasing To initiate a new production deployment: + ```sh script/release vX.Y.Z ``` + See `script/release --help` for more information. -> **Note:** -> Every production release will request an approval by the select few people before it can proceed. +> [!NOTE] +> Deployment workflow requires maintainer approval to run. What this does is: + - Builds Linux binaries on Ubuntu; - Builds and signs Windows binaries on Windows; - Builds, signs, and notarizes macOS binaries on macOS; - Uploads all release artifacts to a new GitHub Release; - A new git tag `vX.Y.Z` is created in the remote repository; - The changelog is [generated from the list of merged pull requests](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes); -- Updates cli.github.com with the contents of the new release; +- Updates [GitHub CLI marketing site](https://cli.github.com) with the contents of the new release; - Updates the [`gh` Homebrew formula](https://github.com/williammartin/homebrew-core/blob/master/Formula/g/gh.rb) in the [`homebrew/homebrew-core` repo](https://github.com/search?q=repo%3AHomebrew%2Fhomebrew-core+%22gh%22+in%3Atitle&type=pullrequests). +> [!NOTE] +> `Homebrew/formulae.brew.sh` makes new formula versions available every 15 minutes through scheduled [CI workflow](https://github.com/Homebrew/formulae.brew.sh/actions/workflows/tests.yml). +> +> For more information, see https://docs.brew.sh/Formula-Cookbook#an-introduction + To test out the build system while avoiding creating an actual release: + ```sh script/release --staging vX.Y.Z --branch patch-1 -p macos ``` + The build artifacts will be available via `gh run download -n macos`. ## General guidelines -* Features to be released should be reviewed and approved at least one day prior to the release. -* Feature releases should bump up the minor version number. -* Breaking releases should bump up the major version number. These should generally be rare. +- Features to be released should be reviewed and approved at least one day prior + to the release. +- Feature releases should bump up the minor version number. +- Breaking releases should bump up the major version number. These should + generally be rare. ## Test the build system locally -A local release can be created for testing without creating anything official on the release page. +A local release can be created for testing without creating anything official on +the release page. 1. Make sure GoReleaser is installed: `brew install goreleaser` 2. `script/release --local` @@ -45,5 +58,6 @@ Occasionally, it might be necessary to clean up a bad release and re-release. 1. Delete the release and associated tag 2. Re-release and monitor the workflow run logs -3. Open pull request updating [`gh` Homebrew formula](https://github.com/williammartin/homebrew-core/blob/master/Formula/g/gh.rb) with new SHA versions, linking the previous PR +3. Open pull request updating [`gh` Homebrew formula](https://github.com/williammartin/homebrew-core/blob/master/Formula/g/gh.rb) + with new SHA versions, linking the previous PR 4. Verify resulting Debian and RPM packages, Homebrew formula diff --git a/go.mod b/go.mod index 38837b883..4e88a54d5 100644 --- a/go.mod +++ b/go.mod @@ -51,7 +51,7 @@ require ( golang.org/x/term v0.28.0 golang.org/x/text v0.21.0 google.golang.org/grpc v1.69.4 - google.golang.org/protobuf v1.36.4 + google.golang.org/protobuf v1.36.5 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 883ac72ce..d58e8de5b 100644 --- a/go.sum +++ b/go.sum @@ -559,8 +559,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= -google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= -google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index a6eb69718..a1308d09f 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -463,9 +463,11 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW var serverError string if isJSON && (opts.RequestPath == "graphql" || resp.StatusCode >= 400) { - responseBody, serverError, err = parseErrorResponse(responseBody, resp.StatusCode) - if err != nil { - return + if !strings.EqualFold(opts.RequestMethod, "HEAD") { + responseBody, serverError, err = parseErrorResponse(responseBody, resp.StatusCode) + if err != nil { + return + } } } diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index a565312cd..321f7b7c0 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -1234,6 +1234,35 @@ func Test_apiRun_DELETE(t *testing.T) { } } +func Test_apiRun_HEAD(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + err := apiRun(&ApiOptions{ + IO: ios, + Config: func() (gh.Config, error) { + return config.NewBlankConfig(), nil + }, + HttpClient: func() (*http.Client, error) { + var tr roundTripper = func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 422, + Request: req, + Header: map[string][]string{ + "Content-Type": {"application/json"}, + }}, nil + } + return &http.Client{Transport: tr}, nil + }, + MagicFields: []string(nil), + RawFields: []string(nil), + RequestMethod: "HEAD", + RequestMethodPassed: true, + }) + if err != cmdutil.SilentError { + t.Fatalf("got error %v", err) + } +} + func Test_apiRun_inputFile(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index 90242a9fe..0a8de8b45 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -236,7 +236,7 @@ func runVerify(opts *Options) error { 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 err + return fmt.Errorf("no matching predicate found") } attestations = filteredAttestations diff --git a/pkg/cmd/attestation/verify/verify_test.go b/pkg/cmd/attestation/verify/verify_test.go index 87ffa96f0..092a009d8 100644 --- a/pkg/cmd/attestation/verify/verify_test.go +++ b/pkg/cmd/attestation/verify/verify_test.go @@ -501,6 +501,18 @@ func TestRunVerify(t *testing.T) { require.Nil(t, runVerify(&customOpts)) }) + t.Run("with valid OCI artifact with UseBundleFromRegistry flag and unknown predicate type", func(t *testing.T) { + customOpts := publicGoodOpts + customOpts.ArtifactPath = "oci://ghcr.io/github/test" + customOpts.BundlePath = "" + customOpts.UseBundleFromRegistry = true + customOpts.PredicateType = "https://predicate.type" + + err := runVerify(&customOpts) + require.Error(t, err) + require.ErrorContains(t, err, "no matching predicate found") + }) + t.Run("with valid OCI artifact with UseBundleFromRegistry flag but no bundle return from registry", func(t *testing.T) { customOpts := publicGoodOpts customOpts.ArtifactPath = "oci://ghcr.io/github/test" diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 2436934bf..b7b390bf2 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -9,6 +9,7 @@ import ( "strconv" "time" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" @@ -51,7 +52,15 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "status", Short: "Show status of relevant pull requests", - Args: cmdutil.NoArgsQuoteReminder, + Long: heredoc.Docf(` + Show status of relevant pull requests. + + The status shows a summary of pull requests that includes information such as + pull request number, title, CI checks, reviews, etc. + + To see more details of CI checks, run %[1]sgh pr checks%[1]s. + `, "`"), + Args: cmdutil.NoArgsQuoteReminder, RunE: func(cmd *cobra.Command, args []string) error { // support `-R, --repo` override opts.BaseRepo = f.BaseRepo diff --git a/pkg/cmd/project/item-edit/item_edit.go b/pkg/cmd/project/item-edit/item_edit.go index 63dcb3b39..657af1f54 100644 --- a/pkg/cmd/project/item-edit/item_edit.go +++ b/pkg/cmd/project/item-edit/item_edit.go @@ -23,7 +23,7 @@ type editItemOpts struct { fieldID string projectID string text string - number float32 + number float64 date string singleSelectOptionID string iterationID string @@ -123,7 +123,7 @@ func NewCmdEditItem(f *cmdutil.Factory, runF func(config editItemConfig) error) editItemCmd.Flags().StringVar(&opts.fieldID, "field-id", "", "ID of the field to update") editItemCmd.Flags().StringVar(&opts.projectID, "project-id", "", "ID of the project to which the field belongs to") editItemCmd.Flags().StringVar(&opts.text, "text", "", "Text value for the field") - editItemCmd.Flags().Float32Var(&opts.number, "number", 0, "Number value for the field") + editItemCmd.Flags().Float64Var(&opts.number, "number", 0, "Number value for the field") editItemCmd.Flags().StringVar(&opts.date, "date", "", "Date value for the field (YYYY-MM-DD)") editItemCmd.Flags().StringVar(&opts.singleSelectOptionID, "single-select-option-id", "", "ID of the single select option value to set on the field") editItemCmd.Flags().StringVar(&opts.iterationID, "iteration-id", "", "ID of the iteration value to set on the field") diff --git a/pkg/cmd/project/item-edit/item_edit_test.go b/pkg/cmd/project/item-edit/item_edit_test.go index fb9097d19..2f9a16df6 100644 --- a/pkg/cmd/project/item-edit/item_edit_test.go +++ b/pkg/cmd/project/item-edit/item_edit_test.go @@ -47,6 +47,14 @@ func TestNewCmdeditItem(t *testing.T) { itemID: "123", }, }, + { + name: "number with floating point value", + cli: "--number 123.45 --id 123", + wants: editItemOpts{ + number: 123.45, + itemID: "123", + }, + }, { name: "field-id", cli: "--field-id FIELD_ID --id 123", @@ -255,7 +263,7 @@ func TestRunItemEdit_Number(t *testing.T) { // edit item gock.New("https://api.github.com"). Post("/graphql"). - BodyString(`{"query":"mutation UpdateItemValues.*","variables":{"input":{"projectId":"project_id","itemId":"item_id","fieldId":"field_id","value":{"number":2}}}}`). + BodyString(`{"query":"mutation UpdateItemValues.*","variables":{"input":{"projectId":"project_id","itemId":"item_id","fieldId":"field_id","value":{"number":123.45}}}}`). Reply(200). JSON(map[string]interface{}{ "data": map[string]interface{}{ @@ -284,7 +292,7 @@ func TestRunItemEdit_Number(t *testing.T) { config := editItemConfig{ io: ios, opts: editItemOpts{ - number: 2, + number: 123.45, itemID: "item_id", projectID: "project_id", fieldID: "field_id", diff --git a/pkg/cmd/project/shared/queries/queries.go b/pkg/cmd/project/shared/queries/queries.go index 5855e906e..3e63465dd 100644 --- a/pkg/cmd/project/shared/queries/queries.go +++ b/pkg/cmd/project/shared/queries/queries.go @@ -250,7 +250,7 @@ type FieldValueNodes struct { Field ProjectField } `graphql:"... on ProjectV2ItemFieldLabelValue"` ProjectV2ItemFieldNumberValue struct { - Number float32 + Number float64 Field ProjectField } `graphql:"... on ProjectV2ItemFieldNumberValue"` ProjectV2ItemFieldSingleSelectValue struct { diff --git a/pkg/cmd/repo/autolink/autolink.go b/pkg/cmd/repo/autolink/autolink.go index 09b766b85..b4f7c6839 100644 --- a/pkg/cmd/repo/autolink/autolink.go +++ b/pkg/cmd/repo/autolink/autolink.go @@ -3,6 +3,7 @@ package autolink import ( "github.com/MakeNowJust/heredoc" cmdCreate "github.com/cli/cli/v2/pkg/cmd/repo/autolink/create" + cmdDelete "github.com/cli/cli/v2/pkg/cmd/repo/autolink/delete" 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" @@ -26,6 +27,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)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) return cmd } diff --git a/pkg/cmd/repo/autolink/create/create.go b/pkg/cmd/repo/autolink/create/create.go index aad71a941..f06ad5cf9 100644 --- a/pkg/cmd/repo/autolink/create/create.go +++ b/pkg/cmd/repo/autolink/create/create.go @@ -112,7 +112,7 @@ func createRun(opts *createOptions) error { "%s Created repository autolink %s on %s\n", cs.SuccessIconWithColor(cs.Green), cs.Cyanf("%d", autolink.ID), - ghrepo.FullName(repo)) + cs.Bold(ghrepo.FullName(repo))) return nil } diff --git a/pkg/cmd/repo/autolink/create/create_test.go b/pkg/cmd/repo/autolink/create/create_test.go index 477d28da9..fe357f2ee 100644 --- a/pkg/cmd/repo/autolink/create/create_test.go +++ b/pkg/cmd/repo/autolink/create/create_test.go @@ -92,11 +92,11 @@ func TestNewCmdCreate(t *testing.T) { } } -type stubAutoLinkCreator struct { +type stubAutolinkCreator struct { err error } -func (g stubAutoLinkCreator) Create(repo ghrepo.Interface, request AutolinkCreateRequest) (*shared.Autolink, error) { +func (g stubAutolinkCreator) Create(repo ghrepo.Interface, request AutolinkCreateRequest) (*shared.Autolink, error) { if g.err != nil { return nil, g.err } @@ -119,7 +119,7 @@ func TestCreateRun(t *testing.T) { tests := []struct { name string opts *createOptions - stubCreator stubAutoLinkCreator + stubCreator stubAutolinkCreator expectedErr error errMsg string wantStdout string @@ -131,7 +131,7 @@ func TestCreateRun(t *testing.T) { KeyPrefix: "TICKET-", URLTemplate: "https://example.com/TICKET?query=", }, - stubCreator: stubAutoLinkCreator{}, + stubCreator: stubAutolinkCreator{}, wantStdout: "✓ Created repository autolink 1 on OWNER/REPO\n", }, { @@ -141,7 +141,7 @@ func TestCreateRun(t *testing.T) { URLTemplate: "https://example.com/TICKET?query=", Numeric: true, }, - stubCreator: stubAutoLinkCreator{}, + stubCreator: stubAutolinkCreator{}, wantStdout: "✓ Created repository autolink 1 on OWNER/REPO\n", }, { @@ -150,7 +150,7 @@ func TestCreateRun(t *testing.T) { KeyPrefix: "TICKET-", URLTemplate: "https://example.com/TICKET?query=", }, - stubCreator: stubAutoLinkCreator{err: testAutolinkClientCreateError{}}, + stubCreator: stubAutolinkCreator{err: testAutolinkClientCreateError{}}, expectedErr: testAutolinkClientCreateError{}, errMsg: fmt.Sprint("error creating autolink: ", testAutolinkClientCreateError{}.Error()), }, diff --git a/pkg/cmd/repo/autolink/create/http.go b/pkg/cmd/repo/autolink/create/http.go index d7ee940b9..5f187319f 100644 --- a/pkg/cmd/repo/autolink/create/http.go +++ b/pkg/cmd/repo/autolink/create/http.go @@ -45,10 +45,6 @@ func (a *AutolinkCreator) Create(repo ghrepo.Interface, request AutolinkCreateRe defer resp.Body.Close() - // if resp.StatusCode != http.StatusCreated { - // return nil, api.HandleHTTPError(resp) - // } - err = handleAutolinkCreateError(resp) if err != nil { diff --git a/pkg/cmd/repo/autolink/create/http_test.go b/pkg/cmd/repo/autolink/create/http_test.go index 9ef5e0da5..f5e2f5365 100644 --- a/pkg/cmd/repo/autolink/create/http_test.go +++ b/pkg/cmd/repo/autolink/create/http_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestAutoLinkCreator_Create(t *testing.T) { +func TestAutolinkCreator_Create(t *testing.T) { repo := ghrepo.New("OWNER", "REPO") tests := []struct { diff --git a/pkg/cmd/repo/autolink/delete/delete.go b/pkg/cmd/repo/autolink/delete/delete.go new file mode 100644 index 000000000..e5cd874fb --- /dev/null +++ b/pkg/cmd/repo/autolink/delete/delete.go @@ -0,0 +1,107 @@ +package delete + +import ( + "fmt" + + "github.com/cli/cli/v2/internal/browser" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmd/repo/autolink/view" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type deleteOptions struct { + BaseRepo func() (ghrepo.Interface, error) + Browser browser.Browser + AutolinkDeleteClient AutolinkDeleteClient + AutolinkViewClient view.AutolinkViewClient + IO *iostreams.IOStreams + + ID string + Confirmed bool + Prompter prompter.Prompter +} + +type AutolinkDeleteClient interface { + Delete(repo ghrepo.Interface, id string) error +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*deleteOptions) error) *cobra.Command { + opts := &deleteOptions{ + Browser: f.Browser, + IO: f.IOStreams, + Prompter: f.Prompter, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an autolink reference", + Long: "Delete an autolink reference for a repository.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.BaseRepo = f.BaseRepo + + httpClient, err := f.HttpClient() + if err != nil { + return err + } + + opts.AutolinkDeleteClient = &AutolinkDeleter{HTTPClient: httpClient} + opts.AutolinkViewClient = &view.AutolinkViewer{HTTPClient: httpClient} + opts.ID = args[0] + + if !opts.IO.CanPrompt() && !opts.Confirmed { + return cmdutil.FlagErrorf("--yes required when not running interactively") + } + + if runF != nil { + return runF(opts) + } + + return deleteRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.Confirmed, "yes", false, "Confirm deletion without prompting") + + return cmd +} + +func deleteRun(opts *deleteOptions) error { + repo, err := opts.BaseRepo() + if err != nil { + return err + } + + out := opts.IO.Out + cs := opts.IO.ColorScheme() + + autolink, err := opts.AutolinkViewClient.View(repo, opts.ID) + + if err != nil { + return fmt.Errorf("%s %w", cs.Red("error deleting autolink:"), err) + } + + if opts.IO.CanPrompt() && !opts.Confirmed { + fmt.Fprintf(out, "Autolink %s has key prefix %s.\n", cs.Cyan(opts.ID), autolink.KeyPrefix) + + err := opts.Prompter.ConfirmDeletion(autolink.KeyPrefix) + + if err != nil { + return err + } + } + + err = opts.AutolinkDeleteClient.Delete(repo, opts.ID) + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(out, "%s Autolink %s deleted from %s\n", cs.SuccessIcon(), cs.Cyan(opts.ID), cs.Bold(ghrepo.FullName(repo))) + } + + return nil +} diff --git a/pkg/cmd/repo/autolink/delete/delete_test.go b/pkg/cmd/repo/autolink/delete/delete_test.go new file mode 100644 index 000000000..7606ebe33 --- /dev/null +++ b/pkg/cmd/repo/autolink/delete/delete_test.go @@ -0,0 +1,335 @@ +package delete + +import ( + "bytes" + "errors" + "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/internal/prompter" + "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/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdDelete(t *testing.T) { + tests := []struct { + name string + input string + output deleteOptions + isTTY bool + wantErr bool + errMsg string + }{ + { + name: "no argument", + input: "", + isTTY: true, + wantErr: true, + errMsg: "accepts 1 arg(s), received 0", + }, + { + name: "id provided", + input: "123", + isTTY: true, + output: deleteOptions{ID: "123"}, + }, + { + name: "yes flag", + input: "123 --yes", + isTTY: true, + output: deleteOptions{ID: "123", Confirmed: true}, + }, + { + name: "non-TTY", + input: "123 --yes", + isTTY: false, + output: deleteOptions{ID: "123", Confirmed: true}, + }, + { + name: "non-TTY missing yes flag", + input: "123", + isTTY: false, + wantErr: true, + errMsg: "--yes required when not running interactively", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + ios.SetStdinTTY(tt.isTTY) + ios.SetStdoutTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + + 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 *deleteOptions + cmd := NewCmdDelete(f, func(opts *deleteOptions) 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.output.ID, gotOpts.ID) + assert.Equal(t, tt.output.Confirmed, gotOpts.Confirmed) + } + }) + } +} + +type stubAutolinkDeleter struct { + err error +} + +func (d *stubAutolinkDeleter) Delete(repo ghrepo.Interface, id string) error { + return d.err +} + +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 +} + +var errTestPrompt = errors.New("prompt error") +var errTestAutolinkClientView = errors.New("autolink client view error") +var errTestAutolinkClientDelete = errors.New("autolink client delete error") + +func TestDeleteRun(t *testing.T) { + tests := []struct { + name string + opts *deleteOptions + isTTY bool + stubDeleter stubAutolinkDeleter + stubViewer stubAutolinkViewer + prompterStubs func(*prompter.PrompterMock) + + wantStdout string + expectedErr error + expectedErrMsg string + }{ + { + name: "delete", + opts: &deleteOptions{ + ID: "123", + }, + isTTY: true, + stubViewer: stubAutolinkViewer{ + autolink: &shared.Autolink{ + ID: 123, + KeyPrefix: "TICKET-", + URLTemplate: "https://example.com/TICKET?query=", + IsAlphanumeric: true, + }, + }, + stubDeleter: stubAutolinkDeleter{ + err: nil, + }, + prompterStubs: func(pm *prompter.PrompterMock) { + pm.ConfirmDeletionFunc = func(_ string) error { + return nil + } + }, + wantStdout: heredoc.Doc(` + Autolink 123 has key prefix TICKET-. + ✓ Autolink 123 deleted from OWNER/REPO + `), + }, + { + name: "delete with confirm flag", + opts: &deleteOptions{ + ID: "123", + Confirmed: true, + }, + isTTY: true, + stubViewer: stubAutolinkViewer{ + autolink: &shared.Autolink{ + ID: 123, + KeyPrefix: "TICKET-", + URLTemplate: "https://example.com/TICKET?query=", + IsAlphanumeric: true, + }, + }, + stubDeleter: stubAutolinkDeleter{}, + wantStdout: "✓ Autolink 123 deleted from OWNER/REPO\n", + }, + { + name: "confirmation fails", + opts: &deleteOptions{ + ID: "123", + }, + isTTY: true, + stubViewer: stubAutolinkViewer{ + autolink: &shared.Autolink{ + ID: 123, + KeyPrefix: "TICKET-", + URLTemplate: "https://example.com/TICKET?query=", + IsAlphanumeric: true, + }, + }, + stubDeleter: stubAutolinkDeleter{}, + prompterStubs: func(pm *prompter.PrompterMock) { + pm.ConfirmDeletionFunc = func(_ string) error { + return errTestPrompt + } + }, + expectedErr: errTestPrompt, + expectedErrMsg: errTestPrompt.Error(), + wantStdout: "Autolink 123 has key prefix TICKET-.\n", + }, + { + name: "view error", + opts: &deleteOptions{ + ID: "123", + }, + isTTY: true, + stubViewer: stubAutolinkViewer{ + err: errTestAutolinkClientView, + }, + stubDeleter: stubAutolinkDeleter{}, + expectedErr: errTestAutolinkClientView, + expectedErrMsg: "error deleting autolink: autolink client view error", + }, + { + name: "delete error", + opts: &deleteOptions{ + ID: "123", + }, + isTTY: true, + stubViewer: stubAutolinkViewer{ + autolink: &shared.Autolink{ + ID: 123, + KeyPrefix: "TICKET-", + URLTemplate: "https://example.com/TICKET?query=", + IsAlphanumeric: true, + }, + }, + stubDeleter: stubAutolinkDeleter{ + err: errTestAutolinkClientDelete, + }, + prompterStubs: func(pm *prompter.PrompterMock) { + pm.ConfirmDeletionFunc = func(_ string) error { + return nil + } + }, + expectedErr: errTestAutolinkClientDelete, + expectedErrMsg: errTestAutolinkClientDelete.Error(), + wantStdout: "Autolink 123 has key prefix TICKET-.\n", + }, + { + name: "no TTY", + opts: &deleteOptions{ + ID: "123", + Confirmed: true, + }, + isTTY: false, + stubViewer: stubAutolinkViewer{ + autolink: &shared.Autolink{ + ID: 123, + KeyPrefix: "TICKET-", + URLTemplate: "https://example.com/TICKET?query=", + IsAlphanumeric: true, + }}, + stubDeleter: stubAutolinkDeleter{}, + wantStdout: "", + }, + { + name: "no TTY view error", + opts: &deleteOptions{ + ID: "123", + }, + isTTY: false, + stubViewer: stubAutolinkViewer{ + err: errTestAutolinkClientView, + }, + stubDeleter: stubAutolinkDeleter{}, + expectedErr: errTestAutolinkClientView, + expectedErrMsg: "error deleting autolink: autolink client view error", + }, + { + name: "no TTY delete error", + opts: &deleteOptions{ + ID: "123", + Confirmed: true, + }, + isTTY: false, + stubViewer: stubAutolinkViewer{ + autolink: &shared.Autolink{ + ID: 123, + KeyPrefix: "TICKET-", + URLTemplate: "https://example.com/TICKET?query=", + IsAlphanumeric: true, + }, + }, + stubDeleter: stubAutolinkDeleter{ + err: errTestAutolinkClientDelete, + }, + expectedErr: errTestAutolinkClientDelete, + expectedErrMsg: errTestAutolinkClientDelete.Error(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + + ios.SetStdinTTY(tt.isTTY) + ios.SetStdoutTTY(tt.isTTY) + + 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.AutolinkDeleteClient = &tt.stubDeleter + opts.AutolinkViewClient = &tt.stubViewer + + pm := &prompter.PrompterMock{} + if tt.prompterStubs != nil { + tt.prompterStubs(pm) + } + tt.opts.Prompter = pm + + err := deleteRun(opts) + + if tt.expectedErr != nil { + require.Error(t, err, "expected error but got none") + assert.ErrorIs(t, err, tt.expectedErr, "unexpected error") + assert.Equal(t, tt.expectedErrMsg, err.Error(), "unexpected error message") + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") + }) + } +} diff --git a/pkg/cmd/repo/autolink/delete/http.go b/pkg/cmd/repo/autolink/delete/http.go new file mode 100644 index 000000000..d6bc53e84 --- /dev/null +++ b/pkg/cmd/repo/autolink/delete/http.go @@ -0,0 +1,37 @@ +package delete + +import ( + "fmt" + "net/http" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" +) + +type AutolinkDeleter struct { + HTTPClient *http.Client +} + +func (a *AutolinkDeleter) Delete(repo ghrepo.Interface, id string) 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.MethodDelete, url, nil) + if err != nil { + return err + } + + resp, err := a.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("error deleting autolink: HTTP 404: Perhaps you are missing admin rights to the repository? (https://api.github.com/%s)", path) + } else if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + + return nil +} diff --git a/pkg/cmd/repo/autolink/delete/http_test.go b/pkg/cmd/repo/autolink/delete/http_test.go new file mode 100644 index 000000000..a2676178d --- /dev/null +++ b/pkg/cmd/repo/autolink/delete/http_test.go @@ -0,0 +1,73 @@ +package delete + +import ( + "fmt" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/require" +) + +func TestAutolinkDeleter_Delete(t *testing.T) { + repo := ghrepo.New("OWNER", "REPO") + + tests := []struct { + name string + id string + stubStatus int + stubRespJSON string + + expectErr bool + expectedErrMsg string + }{ + { + name: "204 successful delete", + id: "123", + stubStatus: http.StatusNoContent, + }, + { + 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"}`, + stubStatus: http.StatusInternalServerError, + expectErr: true, + expectedErrMsg: "HTTP 500 (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.MethodDelete, + fmt.Sprintf("repos/%s/%s/autolinks/%s", repo.RepoOwner(), repo.RepoName(), tt.id), + ), + httpmock.StatusJSONResponse(tt.stubStatus, tt.stubRespJSON), + ) + defer reg.Verify(t) + + autolinkDeleter := &AutolinkDeleter{ + HTTPClient: &http.Client{Transport: reg}, + } + + err := autolinkDeleter.Delete(repo, tt.id) + + if tt.expectErr { + require.EqualError(t, err, tt.expectedErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/cmd/repo/autolink/list/http_test.go b/pkg/cmd/repo/autolink/list/http_test.go index 65289c419..065444a89 100644 --- a/pkg/cmd/repo/autolink/list/http_test.go +++ b/pkg/cmd/repo/autolink/list/http_test.go @@ -23,7 +23,7 @@ func TestAutolinkLister_List(t *testing.T) { name: "no autolinks", repo: ghrepo.New("OWNER", "REPO"), resp: []shared.Autolink{}, - status: 200, + status: http.StatusOK, }, { name: "two autolinks", @@ -42,12 +42,12 @@ func TestAutolinkLister_List(t *testing.T) { URLTemplate: "https://example2.com", }, }, - status: 200, + status: http.StatusOK, }, { name: "http error", repo: ghrepo.New("OWNER", "REPO"), - status: 404, + status: http.StatusNotFound, }, } @@ -64,7 +64,7 @@ func TestAutolinkLister_List(t *testing.T) { HTTPClient: &http.Client{Transport: reg}, } autolinks, err := autolinkLister.List(tt.repo) - if tt.status == 404 { + if tt.status == http.StatusNotFound { require.Error(t, err) assert.Equal(t, "error getting autolinks: HTTP 404: Perhaps you are missing admin rights to the repository? (https://api.github.com/repos/OWNER/REPO/autolinks)", err.Error()) } else { diff --git a/pkg/cmd/repo/autolink/list/list.go b/pkg/cmd/repo/autolink/list/list.go index 402479c44..fb726d369 100644 --- a/pkg/cmd/repo/autolink/list/list.go +++ b/pkg/cmd/repo/autolink/list/list.go @@ -89,8 +89,14 @@ func listRun(opts *listOptions) error { return err } + cs := opts.IO.ColorScheme() + if len(autolinks) == 0 { - return cmdutil.NewNoResultsError(fmt.Sprintf("no autolinks found in %s", ghrepo.FullName(repo))) + return cmdutil.NewNoResultsError( + fmt.Sprintf( + "no autolinks found in %s", + cs.Bold(ghrepo.FullName(repo))), + ) } if opts.Exporter != nil { @@ -98,14 +104,16 @@ func listRun(opts *listOptions) error { } if opts.IO.IsStdoutTTY() { - title := listHeader(ghrepo.FullName(repo), len(autolinks)) + title := fmt.Sprintf( + "Showing %s in %s", + text.Pluralize(len(autolinks), "autolink reference"), + cs.Bold(ghrepo.FullName(repo)), + ) fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) } tp := tableprinter.New(opts.IO, tableprinter.WithHeader("ID", "KEY PREFIX", "URL TEMPLATE", "ALPHANUMERIC")) - cs := opts.IO.ColorScheme() - for _, autolink := range autolinks { tp.AddField(cs.Cyanf("%d", autolink.ID)) tp.AddField(autolink.KeyPrefix) @@ -116,7 +124,3 @@ func listRun(opts *listOptions) error { return tp.Render() } - -func listHeader(repoName string, count int) string { - return fmt.Sprintf("Showing %s in %s", text.Pluralize(count, "autolink reference"), repoName) -} diff --git a/pkg/cmd/repo/autolink/list/list_test.go b/pkg/cmd/repo/autolink/list/list_test.go index 1e4d73ab8..81acfbbdf 100644 --- a/pkg/cmd/repo/autolink/list/list_test.go +++ b/pkg/cmd/repo/autolink/list/list_test.go @@ -96,12 +96,12 @@ func TestNewCmdList(t *testing.T) { } } -type stubAutoLinkLister struct { +type stubAutolinkLister struct { autolinks []shared.Autolink err error } -func (g stubAutoLinkLister) List(repo ghrepo.Interface) ([]shared.Autolink, error) { +func (g stubAutolinkLister) List(repo ghrepo.Interface) ([]shared.Autolink, error) { return g.autolinks, g.err } @@ -116,7 +116,7 @@ func TestListRun(t *testing.T) { name string opts *listOptions isTTY bool - stubLister stubAutoLinkLister + stubLister stubAutolinkLister expectedErr error wantStdout string wantStderr string @@ -125,7 +125,7 @@ func TestListRun(t *testing.T) { name: "list tty", opts: &listOptions{}, isTTY: true, - stubLister: stubAutoLinkLister{ + stubLister: stubAutolinkLister{ autolinks: []shared.Autolink{ { ID: 1, @@ -161,7 +161,7 @@ func TestListRun(t *testing.T) { }(), }, isTTY: true, - stubLister: stubAutoLinkLister{ + stubLister: stubAutolinkLister{ autolinks: []shared.Autolink{ { ID: 1, @@ -184,7 +184,7 @@ func TestListRun(t *testing.T) { name: "list non-tty", opts: &listOptions{}, isTTY: false, - stubLister: stubAutoLinkLister{ + stubLister: stubAutolinkLister{ autolinks: []shared.Autolink{ { ID: 1, @@ -210,7 +210,7 @@ func TestListRun(t *testing.T) { name: "no results", opts: &listOptions{}, isTTY: true, - stubLister: stubAutoLinkLister{ + stubLister: stubAutolinkLister{ autolinks: []shared.Autolink{}, }, expectedErr: cmdutil.NewNoResultsError("no autolinks found in OWNER/REPO"), @@ -220,7 +220,7 @@ func TestListRun(t *testing.T) { name: "client error", opts: &listOptions{}, isTTY: true, - stubLister: stubAutoLinkLister{ + stubLister: stubAutolinkLister{ autolinks: []shared.Autolink{}, err: testAutolinkClientListError{}, }, diff --git a/pkg/cmd/repo/autolink/view/http.go b/pkg/cmd/repo/autolink/view/http.go index 8dd6dc12d..cc5638613 100644 --- a/pkg/cmd/repo/autolink/view/http.go +++ b/pkg/cmd/repo/autolink/view/http.go @@ -30,7 +30,7 @@ func (a *AutolinkViewer) View(repo ghrepo.Interface, id string) (*shared.Autolin 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) + return nil, fmt.Errorf("HTTP 404: Perhaps you are missing admin rights to the repository? (https://api.github.com/%s)", path) } else if resp.StatusCode > 299 { return nil, api.HandleHTTPError(resp) } diff --git a/pkg/cmd/repo/autolink/view/http_test.go b/pkg/cmd/repo/autolink/view/http_test.go index 5bfe9369f..b792c9750 100644 --- a/pkg/cmd/repo/autolink/view/http_test.go +++ b/pkg/cmd/repo/autolink/view/http_test.go @@ -28,7 +28,7 @@ func TestAutolinkViewer_View(t *testing.T) { { name: "200 successful alphanumeric view", id: "123", - stubStatus: 200, + stubStatus: http.StatusOK, stubRespJSON: `{ "id": 123, "key_prefix": "TICKET-", @@ -45,7 +45,7 @@ func TestAutolinkViewer_View(t *testing.T) { { name: "200 successful numeric view", id: "123", - stubStatus: 200, + stubStatus: http.StatusOK, stubRespJSON: `{ "id": 123, "key_prefix": "TICKET-", @@ -62,14 +62,14 @@ func TestAutolinkViewer_View(t *testing.T) { { name: "404 repo or autolink not found", id: "123", - stubStatus: 404, + stubStatus: http.StatusNotFound, 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)", + expectedErrMsg: "HTTP 404: Perhaps you are missing admin rights to the repository? (https://api.github.com/repos/OWNER/REPO/autolinks/123)", }, } @@ -85,11 +85,11 @@ func TestAutolinkViewer_View(t *testing.T) { ) defer reg.Verify(t) - autolinkCreator := &AutolinkViewer{ + autolinkViewer := &AutolinkViewer{ HTTPClient: &http.Client{Transport: reg}, } - autolink, err := autolinkCreator.View(repo, tt.id) + autolink, err := autolinkViewer.View(repo, tt.id) if tt.expectErr { require.EqualError(t, err, tt.expectedErrMsg) diff --git a/pkg/cmd/repo/autolink/view/view.go b/pkg/cmd/repo/autolink/view/view.go index 0b212aece..f146cd25b 100644 --- a/pkg/cmd/repo/autolink/view/view.go +++ b/pkg/cmd/repo/autolink/view/view.go @@ -78,7 +78,7 @@ func viewRun(opts *viewOptions) error { return opts.Exporter.Write(opts.IO, autolink) } - fmt.Fprintf(out, "Autolink in %s\n\n", ghrepo.FullName(repo)) + fmt.Fprintf(out, "Autolink in %s\n\n", cs.Bold(ghrepo.FullName(repo))) fmt.Fprint(out, cs.Bold("ID: ")) fmt.Fprintln(out, cs.Cyanf("%d", autolink.ID)) diff --git a/pkg/cmd/repo/autolink/view/view_test.go b/pkg/cmd/repo/autolink/view/view_test.go index bc0a1bf7f..4192822ea 100644 --- a/pkg/cmd/repo/autolink/view/view_test.go +++ b/pkg/cmd/repo/autolink/view/view_test.go @@ -49,13 +49,12 @@ func TestNewCmdView(t *testing.T) { { name: "json flag", input: "123 --json id", - output: viewOptions{}, + output: viewOptions{ID: "123"}, 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", }, @@ -91,17 +90,18 @@ func TestNewCmdView(t *testing.T) { } else { require.NoError(t, err) assert.Equal(t, tt.wantExporter, gotOpts.Exporter != nil) + assert.Equal(t, tt.output.ID, gotOpts.ID) } }) } } -type stubAutoLinkViewer struct { +type stubAutolinkViewer struct { autolink *shared.Autolink err error } -func (g stubAutoLinkViewer) View(repo ghrepo.Interface, id string) (*shared.Autolink, error) { +func (g stubAutolinkViewer) View(repo ghrepo.Interface, id string) (*shared.Autolink, error) { return g.autolink, g.err } @@ -115,7 +115,7 @@ func TestViewRun(t *testing.T) { tests := []struct { name string opts *viewOptions - stubViewer stubAutoLinkViewer + stubViewer stubAutolinkViewer expectedErr error wantStdout string }{ @@ -124,7 +124,7 @@ func TestViewRun(t *testing.T) { opts: &viewOptions{ ID: "1", }, - stubViewer: stubAutoLinkViewer{ + stubViewer: stubAutolinkViewer{ autolink: &shared.Autolink{ ID: 1, KeyPrefix: "TICKET-", @@ -150,7 +150,7 @@ func TestViewRun(t *testing.T) { return exporter }(), }, - stubViewer: stubAutoLinkViewer{ + stubViewer: stubAutolinkViewer{ autolink: &shared.Autolink{ ID: 1, KeyPrefix: "TICKET-", @@ -163,7 +163,7 @@ func TestViewRun(t *testing.T) { { name: "client error", opts: &viewOptions{}, - stubViewer: stubAutoLinkViewer{ + stubViewer: stubAutolinkViewer{ autolink: nil, err: testAutolinkClientViewError{}, },