Merge branch 'trunk' into refactor-get-attestations-funcs

This commit is contained in:
Meredith Lancaster 2025-02-11 11:11:30 -07:00
commit 4daf489c75
28 changed files with 710 additions and 83 deletions

View file

@ -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

View file

@ -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 <RUN> -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

2
go.mod
View file

@ -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
)

4
go.sum
View file

@ -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=

View file

@ -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
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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")

View file

@ -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",

View file

@ -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 {

View file

@ -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
}

View file

@ -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
}

View file

@ -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=<num>",
},
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=<num>",
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=<num>",
},
stubCreator: stubAutoLinkCreator{err: testAutolinkClientCreateError{}},
stubCreator: stubAutolinkCreator{err: testAutolinkClientCreateError{}},
expectedErr: testAutolinkClientCreateError{},
errMsg: fmt.Sprint("error creating autolink: ", testAutolinkClientCreateError{}.Error()),
},

View file

@ -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 {

View file

@ -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 {

View file

@ -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 <id>",
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
}

View file

@ -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=<num>",
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=<num>",
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=<num>",
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=<num>",
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=<num>",
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=<num>",
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")
})
}
}

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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 {

View file

@ -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)
}

View file

@ -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{},
},

View file

@ -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)
}

View file

@ -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)

View file

@ -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))

View file

@ -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{},
},