Merge branch 'trunk' into 9759-gh-extension-install
This commit is contained in:
commit
695f0be1a6
37 changed files with 1354 additions and 366 deletions
2
.github/workflows/deployment.yml
vendored
2
.github/workflows/deployment.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
16
.github/workflows/prauto.yml
vendored
16
.github/workflows/prauto.yml
vendored
|
|
@ -16,7 +16,6 @@ jobs:
|
|||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }}
|
||||
PRID: ${{ github.event.pull_request.node_id }}
|
||||
PRBODY: ${{ github.event.pull_request.body }}
|
||||
PRNUM: ${{ github.event.pull_request.number }}
|
||||
PRHEAD: ${{ github.event.pull_request.head.label }}
|
||||
|
|
@ -43,26 +42,12 @@ jobs:
|
|||
-q ".data.repository.project.columns.nodes[] | select(.name | startswith(\"$1\")) | .id"
|
||||
}
|
||||
|
||||
addToBoard () {
|
||||
gh api graphql --silent -f query='
|
||||
mutation($colID:ID!, $prID:ID!) { addProjectCard(input: { projectColumnId: $colID, contentId: $prID }) { clientMutationId } }
|
||||
' -f colID="$(colID "Needs review")" -f prID="$PRID"
|
||||
}
|
||||
|
||||
if [ "$PR_AUTHOR_TYPE" = "Bot" ] || gh api orgs/cli/public_members/$PRAUTHOR --silent 2>/dev/null
|
||||
then
|
||||
if [ "$PR_AUTHOR_TYPE" != "Bot" ]
|
||||
then
|
||||
gh pr edit $PRNUM --add-assignee $PRAUTHOR
|
||||
fi
|
||||
if ! errtext="$(addToBoard 2>&1)"
|
||||
then
|
||||
cat <<<"$errtext" >&2
|
||||
if ! grep -iq 'project already has the associated issue' <<<"$errtext"
|
||||
then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -86,5 +71,4 @@ jobs:
|
|||
commentPR "Hi! Thanks for the pull request. Please ensure that this change is linked to an issue by mentioning an issue number in the description of the pull request. If this pull request would close the issue, please put the word 'Fixes' before the issue number somewhere in the pull request body. If this is a tiny change like fixing a typo, feel free to ignore this message."
|
||||
fi
|
||||
|
||||
addToBoard
|
||||
exit 0
|
||||
|
|
|
|||
28
acceptance/testdata/pr/pr-view-outside-repo.txtar
vendored
Normal file
28
acceptance/testdata/pr/pr-view-outside-repo.txtar
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Use gh as a credential helper
|
||||
exec gh auth setup-git
|
||||
|
||||
# Create a repository with a file so it has a default branch
|
||||
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
|
||||
|
||||
# Defer repo cleanup
|
||||
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
|
||||
|
||||
# Clone the repo
|
||||
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
|
||||
|
||||
# Prepare a branch to PR
|
||||
cd $SCRIPT_NAME-$RANDOM_STRING
|
||||
exec git checkout -b feature-branch
|
||||
exec git commit --allow-empty -m 'Empty Commit'
|
||||
exec git push -u origin feature-branch
|
||||
|
||||
# Create the PR
|
||||
exec gh pr create --title 'Feature Title' --body 'Feature Body'
|
||||
stdout2env PR_URL
|
||||
|
||||
# Change directories
|
||||
cd ..
|
||||
|
||||
# View the PR
|
||||
exec gh pr view $PR_URL
|
||||
stdout 'Feature Title'
|
||||
104
git/command_test.go
Normal file
104
git/command_test.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
exitCode int
|
||||
stdout string
|
||||
stderr string
|
||||
wantErr *GitError
|
||||
}{
|
||||
{
|
||||
name: "successful command",
|
||||
stdout: "hello world",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "not a repo failure",
|
||||
stdout: "",
|
||||
stderr: "fatal: not a git repository (or any of the parent directories): .git",
|
||||
exitCode: 128,
|
||||
wantErr: &GitError{
|
||||
ExitCode: 128,
|
||||
Stderr: "fatal: not a git repository (or any of the parent directories): .git",
|
||||
err: &exec.ExitError{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
cmd := Command{
|
||||
&exec.Cmd{
|
||||
Path: createMockExecutable(t, tt.stdout, tt.stderr, tt.exitCode),
|
||||
},
|
||||
}
|
||||
|
||||
out, err := cmd.Output()
|
||||
if tt.wantErr != nil {
|
||||
require.Error(t, err)
|
||||
var gitError *GitError
|
||||
require.ErrorAs(t, err, &gitError)
|
||||
assert.Equal(t, tt.wantErr.ExitCode, gitError.ExitCode)
|
||||
assert.Equal(t, tt.wantErr.Stderr, gitError.Stderr)
|
||||
assert.Equal(t, tt.wantErr.Error(), gitError.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.stdout, string(out))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createMockExecutable(t *testing.T, stdout string, stderr string, exitCode int) string {
|
||||
tmpDir := t.TempDir()
|
||||
sourcePath := filepath.Join(tmpDir, "main.go")
|
||||
binaryPath := filepath.Join(tmpDir, "mockexec")
|
||||
if runtime.GOOS == "windows" {
|
||||
binaryPath += ".exe"
|
||||
}
|
||||
|
||||
// Create Go source
|
||||
source := buildCommandSourceCode(exitCode, stdout, stderr)
|
||||
|
||||
// Write source file
|
||||
if err := os.WriteFile(sourcePath, []byte(source), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Compile
|
||||
cmd := exec.Command("go", "build", "-o", binaryPath, sourcePath)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("failed to compile: %v\n%s", err, out)
|
||||
}
|
||||
return binaryPath
|
||||
|
||||
}
|
||||
|
||||
func buildCommandSourceCode(exitCode int, stdout, stderr string) string {
|
||||
return fmt.Sprintf(`package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
func main() {
|
||||
fmt.Printf(%q)
|
||||
fmt.Fprintf(os.Stderr, %q)
|
||||
os.Exit(%d)
|
||||
}`, stdout, stderr, exitCode)
|
||||
}
|
||||
2
go.mod
2
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -239,17 +239,19 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
|
|||
return err
|
||||
}
|
||||
|
||||
if opts.Slurp && !opts.Paginate {
|
||||
return cmdutil.FlagErrorf("`--paginate` required when passing `--slurp`")
|
||||
}
|
||||
if opts.Slurp {
|
||||
if err := cmdutil.MutuallyExclusive(
|
||||
"the `--slurp` option is not supported with `--jq` or `--template`",
|
||||
opts.Slurp,
|
||||
opts.FilterOutput != "",
|
||||
opts.Template != "",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cmdutil.MutuallyExclusive(
|
||||
"the `--slurp` option is not supported with `--jq` or `--template`",
|
||||
opts.Slurp,
|
||||
opts.FilterOutput != "",
|
||||
opts.Template != "",
|
||||
); err != nil {
|
||||
return err
|
||||
if !opts.Paginate {
|
||||
return cmdutil.FlagErrorf("`--paginate` required when passing `--slurp`")
|
||||
}
|
||||
}
|
||||
|
||||
if err := cmdutil.MutuallyExclusive(
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"errors"
|
||||
"github.com/sigstore/sigstore-go/pkg/bundle"
|
||||
)
|
||||
|
||||
|
|
@ -11,18 +10,7 @@ const (
|
|||
GetAttestationByOwnerAndSubjectDigestPath = "orgs/%s/attestations/%s"
|
||||
)
|
||||
|
||||
type ErrNoAttestations struct {
|
||||
name string
|
||||
digest string
|
||||
}
|
||||
|
||||
func (e ErrNoAttestations) Error() string {
|
||||
return fmt.Sprintf("no attestations found for digest %s in %s", e.name, e.digest)
|
||||
}
|
||||
|
||||
func newErrNoAttestations(name, digest string) ErrNoAttestations {
|
||||
return ErrNoAttestations{name, digest}
|
||||
}
|
||||
var ErrNoAttestationsFound = errors.New("no attestations found")
|
||||
|
||||
type Attestation struct {
|
||||
Bundle *bundle.Bundle `json:"bundle"`
|
||||
|
|
|
|||
|
|
@ -60,44 +60,26 @@ func NewLiveClient(hc *http.Client, host string, l *ioconfig.Handler) *LiveClien
|
|||
}
|
||||
}
|
||||
|
||||
func (c *LiveClient) BuildRepoAndDigestURL(repo, digest string) string {
|
||||
repo = strings.Trim(repo, "/")
|
||||
return fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, repo, digest)
|
||||
}
|
||||
|
||||
// GetByRepoAndDigest fetches the attestation by repo and digest
|
||||
func (c *LiveClient) GetByRepoAndDigest(repo, digest string, limit int) ([]*Attestation, error) {
|
||||
url := c.BuildRepoAndDigestURL(repo, digest)
|
||||
attestations, err := c.getAttestations(url, repo, digest, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bundles, err := c.fetchBundleFromAttestations(attestations)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch bundle with URL: %w", err)
|
||||
}
|
||||
|
||||
return bundles, nil
|
||||
}
|
||||
|
||||
func (c *LiveClient) BuildOwnerAndDigestURL(owner, digest string) string {
|
||||
owner = strings.Trim(owner, "/")
|
||||
return fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, owner, digest)
|
||||
c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", digest)
|
||||
url := fmt.Sprintf(GetAttestationByRepoAndSubjectDigestPath, repo, digest)
|
||||
return c.getByURL(url, limit)
|
||||
}
|
||||
|
||||
// GetByOwnerAndDigest fetches attestation by owner and digest
|
||||
func (c *LiveClient) GetByOwnerAndDigest(owner, digest string, limit int) ([]*Attestation, error) {
|
||||
url := c.BuildOwnerAndDigestURL(owner, digest)
|
||||
attestations, err := c.getAttestations(url, owner, digest, limit)
|
||||
c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", digest)
|
||||
url := fmt.Sprintf(GetAttestationByOwnerAndSubjectDigestPath, owner, digest)
|
||||
return c.getByURL(url, limit)
|
||||
}
|
||||
|
||||
func (c *LiveClient) getByURL(url string, limit int) ([]*Attestation, error) {
|
||||
attestations, err := c.getAttestations(url, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(attestations) == 0 {
|
||||
return nil, newErrNoAttestations(owner, digest)
|
||||
}
|
||||
|
||||
bundles, err := c.fetchBundleFromAttestations(attestations)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch bundle with URL: %w", err)
|
||||
|
|
@ -112,9 +94,7 @@ func (c *LiveClient) GetTrustDomain() (string, error) {
|
|||
return c.getTrustDomain(MetaPath)
|
||||
}
|
||||
|
||||
func (c *LiveClient) getAttestations(url, name, digest string, limit int) ([]*Attestation, error) {
|
||||
c.logger.VerbosePrintf("Fetching attestations for artifact digest %s\n\n", digest)
|
||||
|
||||
func (c *LiveClient) getAttestations(url string, limit int) ([]*Attestation, error) {
|
||||
perPage := limit
|
||||
if perPage <= 0 || perPage > maxLimitForFlag {
|
||||
return nil, fmt.Errorf("limit must be greater than 0 and less than or equal to %d", maxLimitForFlag)
|
||||
|
|
@ -157,7 +137,7 @@ func (c *LiveClient) getAttestations(url, name, digest string, limit int) ([]*At
|
|||
}
|
||||
|
||||
if len(attestations) == 0 {
|
||||
return nil, newErrNoAttestations(name, digest)
|
||||
return nil, ErrNoAttestationsFound
|
||||
}
|
||||
|
||||
if len(attestations) > limit {
|
||||
|
|
@ -176,7 +156,7 @@ 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
|
||||
// for now, we fall back 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")
|
||||
fetched[i] = &Attestation{
|
||||
|
|
@ -186,13 +166,14 @@ func (c *LiveClient) fetchBundleFromAttestations(attestations []*Attestation) ([
|
|||
}
|
||||
|
||||
// otherwise fetch the bundle with the provided URL
|
||||
b, err := c.GetBundle(a.BundleURL)
|
||||
b, err := c.getBundle(a.BundleURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch bundle with URL: %w", err)
|
||||
}
|
||||
fetched[i] = &Attestation{
|
||||
Bundle: b,
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
|
@ -204,38 +185,49 @@ func (c *LiveClient) fetchBundleFromAttestations(attestations []*Attestation) ([
|
|||
return fetched, nil
|
||||
}
|
||||
|
||||
func (c *LiveClient) GetBundle(url string) (*bundle.Bundle, error) {
|
||||
func (c *LiveClient) getBundle(url string) (*bundle.Bundle, error) {
|
||||
c.logger.VerbosePrintf("Fetching attestation bundle with bundle URL\n\n")
|
||||
|
||||
resp, err := c.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sgBundle *bundle.Bundle
|
||||
bo := backoff.NewConstantBackOff(getAttestationRetryInterval)
|
||||
err := backoff.Retry(func() error {
|
||||
resp, err := c.httpClient.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request to fetch bundle from URL failed: %w", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode >= 500 && resp.StatusCode <= 599 {
|
||||
return fmt.Errorf("attestation bundle with URL %s returned status code %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read blob storage response body: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read blob storage response body: %w", err)
|
||||
}
|
||||
|
||||
var out []byte
|
||||
decompressed, err := snappy.Decode(out, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decompress with snappy: %w", err)
|
||||
}
|
||||
var out []byte
|
||||
decompressed, err := snappy.Decode(out, body)
|
||||
if err != nil {
|
||||
return backoff.Permanent(fmt.Errorf("failed to decompress with snappy: %w", err))
|
||||
}
|
||||
|
||||
var pbBundle v1.Bundle
|
||||
if err = protojson.Unmarshal(decompressed, &pbBundle); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal to bundle: %w", err)
|
||||
}
|
||||
var pbBundle v1.Bundle
|
||||
if err = protojson.Unmarshal(decompressed, &pbBundle); err != nil {
|
||||
return backoff.Permanent(fmt.Errorf("failed to unmarshal to bundle: %w", err))
|
||||
}
|
||||
|
||||
c.logger.VerbosePrintf("Successfully fetched bundle\n\n")
|
||||
c.logger.VerbosePrintf("Successfully fetched bundle\n\n")
|
||||
|
||||
return bundle.NewBundle(&pbBundle)
|
||||
sgBundle, err = bundle.NewBundle(&pbBundle)
|
||||
if err != nil {
|
||||
return backoff.Permanent(fmt.Errorf("failed to create new bundle: %w", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}, backoff.WithMaxRetries(bo, 3))
|
||||
|
||||
return sgBundle, err
|
||||
}
|
||||
|
||||
func shouldRetry(err error) bool {
|
||||
|
|
|
|||
|
|
@ -42,24 +42,6 @@ func NewClientWithMockGHClient(hasNextPage bool) Client {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetURL(t *testing.T) {
|
||||
c := LiveClient{}
|
||||
|
||||
testData := []struct {
|
||||
repo string
|
||||
digest string
|
||||
expected string
|
||||
}{
|
||||
{repo: "/github/example/", digest: "sha256:12313213", expected: "repos/github/example/attestations/sha256:12313213"},
|
||||
{repo: "/github/example", digest: "sha256:12313213", expected: "repos/github/example/attestations/sha256:12313213"},
|
||||
}
|
||||
|
||||
for _, data := range testData {
|
||||
s := c.BuildRepoAndDigestURL(data.repo, data.digest)
|
||||
require.Equal(t, data.expected, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetByDigest(t *testing.T) {
|
||||
c := NewClientWithMockGHClient(false)
|
||||
attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
|
||||
|
|
@ -150,12 +132,12 @@ func TestGetByDigest_NoAttestationsFound(t *testing.T) {
|
|||
|
||||
attestations, err := c.GetByRepoAndDigest(testRepo, testDigest, DefaultLimit)
|
||||
require.Error(t, err)
|
||||
require.IsType(t, ErrNoAttestations{}, err)
|
||||
require.IsType(t, ErrNoAttestationsFound, err)
|
||||
require.Nil(t, attestations)
|
||||
|
||||
attestations, err = c.GetByOwnerAndDigest(testOwner, testDigest, DefaultLimit)
|
||||
require.Error(t, err)
|
||||
require.IsType(t, ErrNoAttestations{}, err)
|
||||
require.IsType(t, ErrNoAttestationsFound, err)
|
||||
require.Nil(t, attestations)
|
||||
}
|
||||
|
||||
|
|
@ -180,7 +162,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,
|
||||
|
|
@ -193,43 +175,50 @@ func TestFetchBundleFromAttestations(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
|
||||
func TestFetchBundleFromAttestations_InvalidAttestation(t *testing.T) {
|
||||
func TestFetchBundleFromAttestations_MissingBundleAndBundleURLFields(t *testing.T) {
|
||||
httpClient := &mockHttpClient{}
|
||||
client := LiveClient{
|
||||
httpClient: httpClient,
|
||||
logger: io.NewTestHandler(),
|
||||
}
|
||||
|
||||
// If both the BundleURL and Bundle fields are empty, the function should
|
||||
// return an error indicating that
|
||||
att1 := Attestation{}
|
||||
attestations := []*Attestation{&att1}
|
||||
fetched, err := client.fetchBundleFromAttestations(attestations)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, fetched, 2)
|
||||
bundles, err := client.fetchBundleFromAttestations(attestations)
|
||||
require.ErrorContains(t, err, "attestation has no bundle or bundle URL")
|
||||
require.Nil(t, bundles, 2)
|
||||
}
|
||||
|
||||
func TestFetchBundleFromAttestations_Fail(t *testing.T) {
|
||||
httpClient := &failAfterOneCallHttpClient{}
|
||||
func TestFetchBundleFromAttestations_FailOnTheSecondAttestation(t *testing.T) {
|
||||
mockHTTPClient := &failAfterNCallsHttpClient{
|
||||
// the initial HTTP request will succeed, which returns a bundle for the first attestation
|
||||
// all following HTTP requests will fail, which means the function fails to fetch a bundle
|
||||
// for the second attestation and the function returns an error
|
||||
FailOnCallN: 2,
|
||||
FailOnAllSubsequentCalls: true,
|
||||
}
|
||||
|
||||
c := &LiveClient{
|
||||
httpClient: httpClient,
|
||||
httpClient: mockHTTPClient,
|
||||
logger: io.NewTestHandler(),
|
||||
}
|
||||
|
||||
att1 := makeTestAttestation()
|
||||
att2 := makeTestAttestation()
|
||||
attestations := []*Attestation{&att1, &att2}
|
||||
fetched, err := c.fetchBundleFromAttestations(attestations)
|
||||
bundles, err := c.fetchBundleFromAttestations(attestations)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, fetched)
|
||||
httpClient.AssertNumberOfCalls(t, "OnGetFailAfterOneCall", 2)
|
||||
require.Nil(t, bundles)
|
||||
}
|
||||
|
||||
func TestFetchBundleFromAttestations_FetchByURLFail(t *testing.T) {
|
||||
mockHTTPClient := &failHttpClient{}
|
||||
func TestFetchBundleFromAttestations_FailAfterRetrying(t *testing.T) {
|
||||
mockHTTPClient := &reqFailHttpClient{}
|
||||
|
||||
c := &LiveClient{
|
||||
httpClient: mockHTTPClient,
|
||||
|
|
@ -241,10 +230,10 @@ func TestFetchBundleFromAttestations_FetchByURLFail(t *testing.T) {
|
|||
bundle, err := c.fetchBundleFromAttestations(attestations)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, bundle)
|
||||
mockHTTPClient.AssertNumberOfCalls(t, "OnGetFail", 1)
|
||||
mockHTTPClient.AssertNumberOfCalls(t, "OnGetReqFail", 4)
|
||||
}
|
||||
|
||||
func TestFetchBundleByURL_FallbackToBundleField(t *testing.T) {
|
||||
func TestFetchBundleFromAttestations_FallbackToBundleField(t *testing.T) {
|
||||
mockHTTPClient := &mockHttpClient{}
|
||||
|
||||
c := &LiveClient{
|
||||
|
|
@ -252,6 +241,7 @@ func TestFetchBundleByURL_FallbackToBundleField(t *testing.T) {
|
|||
logger: io.NewTestHandler(),
|
||||
}
|
||||
|
||||
// If the bundle URL is empty, the code will fallback to the bundle field
|
||||
a := Attestation{Bundle: data.SigstoreBundle(t)}
|
||||
attestations := []*Attestation{&a}
|
||||
fetched, err := c.fetchBundleFromAttestations(attestations)
|
||||
|
|
@ -260,6 +250,71 @@ func TestFetchBundleByURL_FallbackToBundleField(t *testing.T) {
|
|||
mockHTTPClient.AssertNotCalled(t, "OnGetSuccess")
|
||||
}
|
||||
|
||||
// getBundle successfully fetches a bundle on the first HTTP request attempt
|
||||
func TestGetBundle(t *testing.T) {
|
||||
mockHTTPClient := &mockHttpClient{}
|
||||
|
||||
c := &LiveClient{
|
||||
httpClient: mockHTTPClient,
|
||||
logger: io.NewTestHandler(),
|
||||
}
|
||||
|
||||
b, err := c.getBundle("https://mybundleurl.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "application/vnd.dev.sigstore.bundle.v0.3+json", b.GetMediaType())
|
||||
mockHTTPClient.AssertNumberOfCalls(t, "OnGetSuccess", 1)
|
||||
}
|
||||
|
||||
// getBundle retries successfully when the initial HTTP request returns
|
||||
// a 5XX status code
|
||||
func TestGetBundle_SuccessfulRetry(t *testing.T) {
|
||||
mockHTTPClient := &failAfterNCallsHttpClient{
|
||||
FailOnCallN: 1,
|
||||
FailOnAllSubsequentCalls: false,
|
||||
}
|
||||
|
||||
c := &LiveClient{
|
||||
httpClient: mockHTTPClient,
|
||||
logger: io.NewTestHandler(),
|
||||
}
|
||||
|
||||
b, err := c.getBundle("mybundleurl")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "application/vnd.dev.sigstore.bundle.v0.3+json", b.GetMediaType())
|
||||
mockHTTPClient.AssertNumberOfCalls(t, "OnGetFailAfterNCalls", 2)
|
||||
}
|
||||
|
||||
// getBundle does not retry when the function fails with a permanent backoff error condition
|
||||
func TestGetBundle_PermanentBackoffFail(t *testing.T) {
|
||||
mockHTTPClient := &invalidBundleClient{}
|
||||
c := &LiveClient{
|
||||
httpClient: mockHTTPClient,
|
||||
logger: io.NewTestHandler(),
|
||||
}
|
||||
|
||||
b, err := c.getBundle("mybundleurl")
|
||||
// var permanent *backoff.PermanentError
|
||||
//require.IsType(t, &backoff.PermanentError{}, err)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, b)
|
||||
mockHTTPClient.AssertNumberOfCalls(t, "OnGetInvalidBundle", 1)
|
||||
}
|
||||
|
||||
// getBundle retries when the HTTP request fails
|
||||
func TestGetBundle_RequestFail(t *testing.T) {
|
||||
mockHTTPClient := &reqFailHttpClient{}
|
||||
|
||||
c := &LiveClient{
|
||||
httpClient: mockHTTPClient,
|
||||
logger: io.NewTestHandler(),
|
||||
}
|
||||
|
||||
b, err := c.getBundle("mybundleurl")
|
||||
require.Error(t, err)
|
||||
require.Nil(t, b)
|
||||
mockHTTPClient.AssertNumberOfCalls(t, "OnGetReqFail", 4)
|
||||
}
|
||||
|
||||
func TestGetTrustDomain(t *testing.T) {
|
||||
fetcher := mockMetaGenerator{
|
||||
TrustDomain: "foo",
|
||||
|
|
|
|||
|
|
@ -27,34 +27,55 @@ func (m *mockHttpClient) Get(url string) (*http.Response, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
type failHttpClient struct {
|
||||
type invalidBundleClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *failHttpClient) Get(url string) (*http.Response, error) {
|
||||
m.On("OnGetFail").Return()
|
||||
m.MethodCalled("OnGetFail")
|
||||
func (m *invalidBundleClient) Get(url string) (*http.Response, error) {
|
||||
m.On("OnGetInvalidBundle").Return()
|
||||
m.MethodCalled("OnGetInvalidBundle")
|
||||
|
||||
var compressed []byte
|
||||
compressed = snappy.Encode(compressed, []byte("invalid bundle bytes"))
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewReader(compressed)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type reqFailHttpClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *reqFailHttpClient) Get(url string) (*http.Response, error) {
|
||||
m.On("OnGetReqFail").Return()
|
||||
m.MethodCalled("OnGetReqFail")
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: 500,
|
||||
}, fmt.Errorf("failed to fetch with %s", url)
|
||||
}
|
||||
|
||||
type failAfterOneCallHttpClient struct {
|
||||
type failAfterNCallsHttpClient struct {
|
||||
mock.Mock
|
||||
FailOnCallN int
|
||||
FailOnAllSubsequentCalls bool
|
||||
NumCalls int
|
||||
}
|
||||
|
||||
func (m *failAfterOneCallHttpClient) Get(url string) (*http.Response, error) {
|
||||
m.On("OnGetFailAfterOneCall").Return()
|
||||
func (m *failAfterNCallsHttpClient) Get(url string) (*http.Response, error) {
|
||||
m.On("OnGetFailAfterNCalls").Return()
|
||||
|
||||
if len(m.Calls) >= 1 {
|
||||
m.MethodCalled("OnGetFailAfterOneCall")
|
||||
m.NumCalls++
|
||||
|
||||
if m.NumCalls == m.FailOnCallN || (m.NumCalls > m.FailOnCallN && m.FailOnAllSubsequentCalls) {
|
||||
m.MethodCalled("OnGetFailAfterNCalls")
|
||||
return &http.Response{
|
||||
StatusCode: 500,
|
||||
}, fmt.Errorf("failed to fetch with %s", url)
|
||||
}, nil
|
||||
}
|
||||
|
||||
m.MethodCalled("OnGetFailAfterOneCall")
|
||||
m.MethodCalled("OnGetFailAfterNCalls")
|
||||
var compressed []byte
|
||||
compressed = snappy.Encode(compressed, data.SigstoreBundleRaw)
|
||||
return &http.Response{
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ func runDownload(opts *Options) error {
|
|||
}
|
||||
attestations, err := verification.GetRemoteAttestations(opts.APIClient, params)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrNoAttestations{}) {
|
||||
if errors.Is(err, api.ErrNoAttestationsFound) {
|
||||
fmt.Fprintf(opts.Logger.IO.Out, "No attestations found for %s\n", opts.ArtifactPath)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ func TestRunDownload(t *testing.T) {
|
|||
opts := baseOpts
|
||||
opts.APIClient = api.MockClient{
|
||||
OnGetByOwnerAndDigest: func(repo, digest string, limit int) ([]*api.Attestation, error) {
|
||||
return nil, api.ErrNoAttestations{}
|
||||
return nil, api.ErrNoAttestationsFound
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ func runVerify(opts *Options) error {
|
|||
|
||||
attestations, logMsg, err := getAttestations(opts, *artifact)
|
||||
if err != nil {
|
||||
if ok := errors.Is(err, api.ErrNoAttestations{}); ok {
|
||||
if ok := errors.Is(err, api.ErrNoAttestationsFound); ok {
|
||||
opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg())
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
Use: "list",
|
||||
Short: "List issues in a repository",
|
||||
Long: heredoc.Doc(`
|
||||
List issues in a GitHub repository.
|
||||
List issues in a GitHub repository. By default, this only lists open issues.
|
||||
|
||||
The search query syntax is documented here:
|
||||
<https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests>
|
||||
|
|
@ -70,6 +70,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
$ gh issue list --assignee "@me"
|
||||
$ gh issue list --milestone "The big 1.0"
|
||||
$ gh issue list --search "error no:assignee sort:created-asc"
|
||||
$ gh issue list --state all
|
||||
`),
|
||||
Aliases: []string{"ls"},
|
||||
Args: cmdutil.NoArgsQuoteReminder,
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
cliContext "github.com/cli/cli/v2/context"
|
||||
"github.com/cli/cli/v2/git"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
|
|
@ -25,8 +27,12 @@ type CheckoutOptions struct {
|
|||
Remotes func() (cliContext.Remotes, error)
|
||||
Branch func() (string, error)
|
||||
|
||||
Finder shared.PRFinder
|
||||
Finder shared.PRFinder
|
||||
Prompter shared.Prompt
|
||||
Lister shared.PRLister
|
||||
|
||||
Interactive bool
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
SelectorArg string
|
||||
RecurseSubmodules bool
|
||||
Force bool
|
||||
|
|
@ -42,17 +48,33 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr
|
|||
Config: f.Config,
|
||||
Remotes: f.Remotes,
|
||||
Branch: f.Branch,
|
||||
Prompter: f.Prompter,
|
||||
BaseRepo: f.BaseRepo,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "checkout {<number> | <url> | <branch>}",
|
||||
Use: "checkout [<number> | <url> | <branch>]",
|
||||
Short: "Check out a pull request in git",
|
||||
Args: cmdutil.ExactArgs(1, "argument required"),
|
||||
Example: heredoc.Doc(`
|
||||
# Interactively select a PR from the 10 most recent to check out
|
||||
$ gh pr checkout
|
||||
|
||||
# Checkout a specific PR
|
||||
$ gh pr checkout 32
|
||||
$ gh pr checkout https://github.com/OWNER/REPO/pull/32
|
||||
$ gh pr checkout feature
|
||||
`),
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Finder = shared.NewFinder(f)
|
||||
opts.Lister = shared.NewLister(f)
|
||||
|
||||
if len(args) > 0 {
|
||||
opts.SelectorArg = args[0]
|
||||
} else if !opts.IO.CanPrompt() {
|
||||
return cmdutil.FlagErrorf("pull request number, URL, or branch required when not running interactively")
|
||||
} else {
|
||||
opts.Interactive = true
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
|
|
@ -71,11 +93,12 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr
|
|||
}
|
||||
|
||||
func checkoutRun(opts *CheckoutOptions) error {
|
||||
findOptions := shared.FindOptions{
|
||||
Selector: opts.SelectorArg,
|
||||
Fields: []string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository", "maintainerCanModify"},
|
||||
baseRepo, err := opts.BaseRepo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pr, baseRepo, err := opts.Finder.Find(findOptions)
|
||||
|
||||
pr, err := resolvePR(baseRepo, opts.Prompter, opts.SelectorArg, opts.Interactive, opts.Finder, opts.Lister, opts.IO)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -263,3 +286,77 @@ func executeCmds(client *git.Client, credentialPattern git.CredentialPattern, cm
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolvePR(baseRepo ghrepo.Interface, prompter shared.Prompt, pullRequestSelector string, isInteractive bool, pullRequestFinder shared.PRFinder, prLister shared.PRLister, io *iostreams.IOStreams) (*api.PullRequest, error) {
|
||||
// When non-interactive
|
||||
if pullRequestSelector != "" {
|
||||
pr, _, err := pullRequestFinder.Find(shared.FindOptions{
|
||||
Selector: pullRequestSelector,
|
||||
Fields: []string{
|
||||
"number",
|
||||
"headRefName",
|
||||
"headRepository",
|
||||
"headRepositoryOwner",
|
||||
"isCrossRepository",
|
||||
"maintainerCanModify",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pr, nil
|
||||
}
|
||||
if !isInteractive {
|
||||
return nil, cmdutil.FlagErrorf("pull request number, URL, or branch required when not running interactively")
|
||||
}
|
||||
// When interactive
|
||||
io.StartProgressIndicator()
|
||||
listResult, err := prLister.List(shared.ListOptions{
|
||||
State: "open",
|
||||
Fields: []string{
|
||||
"number",
|
||||
"title",
|
||||
"state",
|
||||
"isDraft",
|
||||
|
||||
"headRefName",
|
||||
"headRepository",
|
||||
"headRepositoryOwner",
|
||||
"isCrossRepository",
|
||||
"maintainerCanModify",
|
||||
},
|
||||
LimitResults: 10})
|
||||
io.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(listResult.PullRequests) == 0 {
|
||||
return nil, shared.ListNoResults(ghrepo.FullName(baseRepo), "pull request", false)
|
||||
}
|
||||
|
||||
pr, err := promptForPR(prompter, *listResult)
|
||||
return pr, err
|
||||
}
|
||||
|
||||
func promptForPR(prompter shared.Prompt, jobs api.PullRequestAndTotalCount) (*api.PullRequest, error) {
|
||||
candidates := []string{}
|
||||
for _, pr := range jobs.PullRequests {
|
||||
candidates = append(candidates, fmt.Sprintf("%d\t%s %s [%s]",
|
||||
pr.Number,
|
||||
shared.PrStateWithDraft(&pr),
|
||||
text.RemoveExcessiveWhitespace(pr.Title),
|
||||
pr.HeadLabel(),
|
||||
))
|
||||
}
|
||||
|
||||
selected, err := prompter.Select("Select a pull request", "", candidates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if selected >= 0 {
|
||||
return &jobs.PullRequests[selected], nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/internal/run"
|
||||
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
|
|
@ -27,6 +28,10 @@ import (
|
|||
// repo: either "baseOwner/baseRepo" or "baseOwner/baseRepo:defaultBranch"
|
||||
// prHead: "headOwner/headRepo:headBranch"
|
||||
func stubPR(repo, prHead string) (ghrepo.Interface, *api.PullRequest) {
|
||||
return _stubPR(repo, prHead, 123, "PR title", "OPEN", false)
|
||||
}
|
||||
|
||||
func _stubPR(repo, prHead string, number int, title string, state string, isDraft bool) (ghrepo.Interface, *api.PullRequest) {
|
||||
defaultBranch := ""
|
||||
if idx := strings.IndexRune(repo, ':'); idx >= 0 {
|
||||
defaultBranch = repo[idx+1:]
|
||||
|
|
@ -52,25 +57,33 @@ func stubPR(repo, prHead string) (ghrepo.Interface, *api.PullRequest) {
|
|||
}
|
||||
|
||||
return baseRepo, &api.PullRequest{
|
||||
Number: 123,
|
||||
Number: number,
|
||||
HeadRefName: headRefName,
|
||||
HeadRepositoryOwner: api.Owner{Login: headRepo.RepoOwner()},
|
||||
HeadRepository: &api.PRRepository{Name: headRepo.RepoName()},
|
||||
IsCrossRepository: !ghrepo.IsSame(baseRepo, headRepo),
|
||||
MaintainerCanModify: false,
|
||||
|
||||
Title: title,
|
||||
State: state,
|
||||
IsDraft: isDraft,
|
||||
}
|
||||
}
|
||||
|
||||
func Test_checkoutRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *CheckoutOptions
|
||||
httpStubs func(*httpmock.Registry)
|
||||
runStubs func(*run.CommandStubber)
|
||||
name string
|
||||
opts *CheckoutOptions
|
||||
|
||||
httpStubs func(*httpmock.Registry)
|
||||
runStubs func(*run.CommandStubber)
|
||||
promptStubs func(*prompter.MockPrompter)
|
||||
|
||||
remotes map[string]string
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "checkout with ssh remote URL",
|
||||
|
|
@ -81,6 +94,10 @@ func Test_checkoutRun(t *testing.T) {
|
|||
finder := shared.NewMockFinder("123", pr, baseRepo)
|
||||
return finder
|
||||
}(),
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
baseRepo, _ := stubPR("OWNER/REPO:master", "OWNER/REPO:feature")
|
||||
return baseRepo, nil
|
||||
},
|
||||
Config: func() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
|
|
@ -108,6 +125,10 @@ func Test_checkoutRun(t *testing.T) {
|
|||
finder := shared.NewMockFinder("123", pr, baseRepo)
|
||||
return finder
|
||||
}(),
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
baseRepo, _ := stubPR("OWNER/REPO:master", "OWNER/REPO:feature")
|
||||
return baseRepo, nil
|
||||
},
|
||||
Config: func() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
|
|
@ -137,6 +158,10 @@ func Test_checkoutRun(t *testing.T) {
|
|||
finder := shared.NewMockFinder("123", pr, baseRepo)
|
||||
return finder
|
||||
}(),
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
baseRepo, _ := stubPR("OWNER/REPO:master", "OWNER/REPO:feature")
|
||||
return baseRepo, nil
|
||||
},
|
||||
Config: func() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
|
|
@ -164,6 +189,10 @@ func Test_checkoutRun(t *testing.T) {
|
|||
finder := shared.NewMockFinder("123", pr, baseRepo)
|
||||
return finder
|
||||
}(),
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
baseRepo, _ := stubPR("OWNER/REPO:master", "hubot/REPO:feature")
|
||||
return baseRepo, nil
|
||||
},
|
||||
Config: func() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
|
|
@ -183,12 +212,87 @@ func Test_checkoutRun(t *testing.T) {
|
|||
cs.Register(`git config branch\.foobar\.merge refs/heads/feature`, 0, "")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with no selected PR args and non tty, return error",
|
||||
opts: &CheckoutOptions{
|
||||
SelectorArg: "",
|
||||
Interactive: false,
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
return ghrepo.New("OWNER", "REPO"), nil
|
||||
},
|
||||
},
|
||||
remotes: map[string]string{
|
||||
"origin": "OWNER/REPO",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "pull request number, URL, or branch required when not running interactively",
|
||||
},
|
||||
{
|
||||
name: "with no selected PR args and stdin tty, prompts for choice",
|
||||
opts: &CheckoutOptions{
|
||||
SelectorArg: "",
|
||||
Interactive: true,
|
||||
Lister: func() shared.PRLister {
|
||||
_, pr1 := _stubPR("OWNER/REPO:master", "OWNER/REPO:feature", 32, "New feature", "OPEN", false)
|
||||
_, pr2 := _stubPR("OWNER/REPO:master", "OWNER/REPO:bug-fix", 29, "Fixed bad bug", "OPEN", false)
|
||||
_, pr3 := _stubPR("OWNER/REPO:master", "OWNER/REPO:docs", 28, "Improve documentation", "OPEN", true)
|
||||
lister := shared.NewMockLister(&api.PullRequestAndTotalCount{
|
||||
TotalCount: 3,
|
||||
PullRequests: []api.PullRequest{
|
||||
*pr1, *pr2, *pr3,
|
||||
}, SearchCapped: false}, nil)
|
||||
lister.ExpectFields([]string{"number", "title", "state", "isDraft", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository", "maintainerCanModify"})
|
||||
return lister
|
||||
}(),
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
return ghrepo.New("OWNER", "REPO"), nil
|
||||
},
|
||||
Config: func() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
},
|
||||
promptStubs: func(pm *prompter.MockPrompter) {
|
||||
pm.RegisterSelect("Select a pull request",
|
||||
[]string{"32\tOPEN New feature [feature]", "29\tOPEN Fixed bad bug [bug-fix]", "28\tDRAFT Improve documentation [docs]"},
|
||||
func(_, _ string, opts []string) (int, error) {
|
||||
return prompter.IndexFor(opts, "32\tOPEN New feature [feature]")
|
||||
})
|
||||
},
|
||||
runStubs: func(cs *run.CommandStubber) {
|
||||
cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "")
|
||||
cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
|
||||
cs.Register(`git checkout -b feature --track origin/feature`, 0, "")
|
||||
},
|
||||
remotes: map[string]string{
|
||||
"origin": "OWNER/REPO",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with no select PR args and no open PR, return error",
|
||||
opts: &CheckoutOptions{
|
||||
SelectorArg: "",
|
||||
Interactive: true,
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
return ghrepo.New("OWNER", "REPO"), nil
|
||||
},
|
||||
Lister: shared.NewMockLister(&api.PullRequestAndTotalCount{
|
||||
TotalCount: 0,
|
||||
PullRequests: []api.PullRequest{},
|
||||
}, nil),
|
||||
},
|
||||
remotes: map[string]string{
|
||||
"origin": "OWNER/REPO",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "no open pull requests in OWNER/REPO",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
opts := tt.opts
|
||||
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
|
||||
opts.IO = ios
|
||||
httpReg := &httpmock.Registry{}
|
||||
defer httpReg.Verify(t)
|
||||
|
|
@ -205,6 +309,12 @@ func Test_checkoutRun(t *testing.T) {
|
|||
tt.runStubs(cmdStubs)
|
||||
}
|
||||
|
||||
pm := prompter.NewMockPrompter(t)
|
||||
tt.opts.Prompter = pm
|
||||
if tt.promptStubs != nil {
|
||||
tt.promptStubs(pm)
|
||||
}
|
||||
|
||||
opts.Remotes = func() (context.Remotes, error) {
|
||||
if len(tt.remotes) == 0 {
|
||||
return nil, errors.New("no remotes")
|
||||
|
|
@ -232,6 +342,9 @@ func Test_checkoutRun(t *testing.T) {
|
|||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("want error: %v, got: %v", tt.wantErr, err)
|
||||
}
|
||||
if err != nil {
|
||||
assert.Equal(t, tt.errMsg, err.Error())
|
||||
}
|
||||
assert.Equal(t, tt.wantStdout, stdout.String())
|
||||
assert.Equal(t, tt.wantStderr, stderr.String())
|
||||
})
|
||||
|
|
@ -240,7 +353,7 @@ func Test_checkoutRun(t *testing.T) {
|
|||
|
||||
/** LEGACY TESTS **/
|
||||
|
||||
func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, cli string) (*test.CmdOut, error) {
|
||||
func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, cli string, baseRepo ghrepo.Interface) (*test.CmdOut, error) {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
|
||||
factory := &cmdutil.Factory{
|
||||
|
|
@ -269,6 +382,9 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, cl
|
|||
GhPath: "some/path/gh",
|
||||
GitPath: "some/path/git",
|
||||
},
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
return baseRepo, nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd := NewCmdCheckout(factory, nil)
|
||||
|
|
@ -305,7 +421,7 @@ func TestPRCheckout_sameRepo(t *testing.T) {
|
|||
cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "")
|
||||
cs.Register(`git checkout -b feature --track origin/feature`, 0, "")
|
||||
|
||||
output, err := runCommand(http, nil, "master", `123`)
|
||||
output, err := runCommand(http, nil, "master", `123`, baseRepo)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
|
|
@ -325,7 +441,7 @@ func TestPRCheckout_existingBranch(t *testing.T) {
|
|||
cs.Register(`git checkout feature`, 0, "")
|
||||
cs.Register(`git merge --ff-only refs/remotes/origin/feature`, 0, "")
|
||||
|
||||
output, err := runCommand(http, nil, "master", `123`)
|
||||
output, err := runCommand(http, nil, "master", `123`, baseRepo)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
|
|
@ -356,7 +472,7 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) {
|
|||
cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "")
|
||||
cs.Register(`git checkout -b feature --track robot-fork/feature`, 0, "")
|
||||
|
||||
output, err := runCommand(http, remotes, "master", `123`)
|
||||
output, err := runCommand(http, remotes, "master", `123`, baseRepo)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
|
|
@ -379,7 +495,7 @@ func TestPRCheckout_differentRepo(t *testing.T) {
|
|||
cs.Register(`git config branch\.feature\.pushRemote origin`, 0, "")
|
||||
cs.Register(`git config branch\.feature\.merge refs/pull/123/head`, 0, "")
|
||||
|
||||
output, err := runCommand(http, nil, "master", `123`)
|
||||
output, err := runCommand(http, nil, "master", `123`, baseRepo)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
|
|
@ -398,7 +514,7 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) {
|
|||
cs.Register(`git config branch\.feature\.merge`, 0, "refs/heads/feature\n")
|
||||
cs.Register(`git checkout feature`, 0, "")
|
||||
|
||||
output, err := runCommand(http, nil, "master", `123`)
|
||||
output, err := runCommand(http, nil, "master", `123`, baseRepo)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
|
|
@ -417,7 +533,7 @@ func TestPRCheckout_detachedHead(t *testing.T) {
|
|||
cs.Register(`git config branch\.feature\.merge`, 0, "refs/heads/feature\n")
|
||||
cs.Register(`git checkout feature`, 0, "")
|
||||
|
||||
output, err := runCommand(http, nil, "", `123`)
|
||||
output, err := runCommand(http, nil, "", `123`, baseRepo)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
|
|
@ -436,7 +552,7 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) {
|
|||
cs.Register(`git config branch\.feature\.merge`, 0, "refs/heads/feature\n")
|
||||
cs.Register(`git merge --ff-only FETCH_HEAD`, 0, "")
|
||||
|
||||
output, err := runCommand(http, nil, "feature", `123`)
|
||||
output, err := runCommand(http, nil, "feature", `123`, baseRepo)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
|
|
@ -451,7 +567,9 @@ func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) {
|
|||
|
||||
_, cmdTeardown := run.Stub()
|
||||
defer cmdTeardown(t)
|
||||
output, err := runCommand(http, nil, "master", `123`)
|
||||
|
||||
output, err := runCommand(http, nil, "master", `123`, baseRepo)
|
||||
|
||||
assert.EqualError(t, err, `invalid branch name: "-foo"`)
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
|
|
@ -474,7 +592,7 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) {
|
|||
cs.Register(`git config branch\.feature\.pushRemote https://github\.com/hubot/REPO\.git`, 0, "")
|
||||
cs.Register(`git config branch\.feature\.merge refs/heads/feature`, 0, "")
|
||||
|
||||
output, err := runCommand(http, nil, "master", `123`)
|
||||
output, err := runCommand(http, nil, "master", `123`, baseRepo)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
|
|
@ -495,7 +613,7 @@ func TestPRCheckout_recurseSubmodules(t *testing.T) {
|
|||
cs.Register(`git submodule sync --recursive`, 0, "")
|
||||
cs.Register(`git submodule update --init --recursive`, 0, "")
|
||||
|
||||
output, err := runCommand(http, nil, "master", `123 --recurse-submodules`)
|
||||
output, err := runCommand(http, nil, "master", `123 --recurse-submodules`, baseRepo)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
|
|
@ -514,7 +632,7 @@ func TestPRCheckout_force(t *testing.T) {
|
|||
cs.Register(`git checkout feature`, 0, "")
|
||||
cs.Register(`git reset --hard refs/remotes/origin/feature`, 0, "")
|
||||
|
||||
output, err := runCommand(http, nil, "master", `123 --force`)
|
||||
output, err := runCommand(http, nil, "master", `123 --force`, baseRepo)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", output.String())
|
||||
|
|
@ -533,7 +651,7 @@ func TestPRCheckout_detach(t *testing.T) {
|
|||
cs.Register(`git checkout --detach FETCH_HEAD`, 0, "")
|
||||
cs.Register(`git fetch origin refs/pull/123/head`, 0, "")
|
||||
|
||||
output, err := runCommand(http, nil, "", `123 --detach`)
|
||||
output, err := runCommand(http, nil, "", `123 --detach`, baseRepo)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
)
|
||||
|
||||
func shouldUseSearch(filters prShared.FilterOptions) bool {
|
||||
|
|
@ -18,113 +19,16 @@ func listPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters pr
|
|||
return searchPullRequests(httpClient, repo, filters, limit)
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Repository struct {
|
||||
PullRequests struct {
|
||||
Nodes []api.PullRequest
|
||||
PageInfo struct {
|
||||
HasNextPage bool
|
||||
EndCursor string
|
||||
}
|
||||
TotalCount int
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment := fmt.Sprintf("fragment pr on PullRequest{%s}", api.PullRequestGraphQL(filters.Fields))
|
||||
query := fragment + `
|
||||
query PullRequestList(
|
||||
$owner: String!,
|
||||
$repo: String!,
|
||||
$limit: Int!,
|
||||
$endCursor: String,
|
||||
$baseBranch: String,
|
||||
$headBranch: String,
|
||||
$state: [PullRequestState!] = OPEN
|
||||
) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(
|
||||
states: $state,
|
||||
baseRefName: $baseBranch,
|
||||
headRefName: $headBranch,
|
||||
first: $limit,
|
||||
after: $endCursor,
|
||||
orderBy: {field: CREATED_AT, direction: DESC}
|
||||
) {
|
||||
totalCount
|
||||
nodes {
|
||||
...pr
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
pageLimit := min(limit, 100)
|
||||
variables := map[string]interface{}{
|
||||
"owner": repo.RepoOwner(),
|
||||
"repo": repo.RepoName(),
|
||||
}
|
||||
|
||||
switch filters.State {
|
||||
case "open":
|
||||
variables["state"] = []string{"OPEN"}
|
||||
case "closed":
|
||||
variables["state"] = []string{"CLOSED", "MERGED"}
|
||||
case "merged":
|
||||
variables["state"] = []string{"MERGED"}
|
||||
case "all":
|
||||
variables["state"] = []string{"OPEN", "CLOSED", "MERGED"}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid state: %s", filters.State)
|
||||
}
|
||||
|
||||
if filters.BaseBranch != "" {
|
||||
variables["baseBranch"] = filters.BaseBranch
|
||||
}
|
||||
if filters.HeadBranch != "" {
|
||||
variables["headBranch"] = filters.HeadBranch
|
||||
}
|
||||
|
||||
res := api.PullRequestAndTotalCount{}
|
||||
var check = make(map[int]struct{})
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
loop:
|
||||
for {
|
||||
variables["limit"] = pageLimit
|
||||
var data response
|
||||
err := client.GraphQL(repo.RepoHost(), query, variables, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prData := data.Repository.PullRequests
|
||||
res.TotalCount = prData.TotalCount
|
||||
|
||||
for _, pr := range prData.Nodes {
|
||||
if _, exists := check[pr.Number]; exists && pr.Number > 0 {
|
||||
continue
|
||||
}
|
||||
check[pr.Number] = struct{}{}
|
||||
|
||||
res.PullRequests = append(res.PullRequests, pr)
|
||||
if len(res.PullRequests) == limit {
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
if prData.PageInfo.HasNextPage {
|
||||
variables["endCursor"] = prData.PageInfo.EndCursor
|
||||
pageLimit = min(pageLimit, limit-len(res.PullRequests))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &res, nil
|
||||
return prShared.NewLister(&cmdutil.Factory{
|
||||
HttpClient: func() (*http.Client, error) { return httpClient, nil },
|
||||
BaseRepo: func() (ghrepo.Interface, error) { return repo, nil },
|
||||
}).List(prShared.ListOptions{
|
||||
LimitResults: limit,
|
||||
State: filters.State,
|
||||
BaseBranch: filters.BaseBranch,
|
||||
HeadBranch: filters.HeadBranch,
|
||||
Fields: filters.Fields,
|
||||
})
|
||||
}
|
||||
|
||||
func searchPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.PullRequestAndTotalCount, error) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
)
|
||||
|
||||
func Test_listPullRequests(t *testing.T) {
|
||||
func Test_ListPullRequests(t *testing.T) {
|
||||
type args struct {
|
||||
repo ghrepo.Interface
|
||||
filters prShared.FilterOptions
|
||||
|
|
@ -155,7 +155,7 @@ func Test_listPullRequests(t *testing.T) {
|
|||
|
||||
_, err := listPullRequests(httpClient, tt.args.repo, tt.args.filters, tt.args.limit)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("listPullRequests() error = %v, wantErr %v", err, tt.wantErr)
|
||||
t.Errorf("ListPullRequests() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
Use: "list",
|
||||
Short: "List pull requests in a repository",
|
||||
Long: heredoc.Doc(`
|
||||
List pull requests in a GitHub repository.
|
||||
List pull requests in a GitHub repository. By default, this only lists open PRs.
|
||||
|
||||
The search query syntax is documented here:
|
||||
<https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests>
|
||||
|
|
@ -222,7 +222,7 @@ func listRun(opts *ListOptions) error {
|
|||
table.AddField(text.RemoveExcessiveWhitespace(pr.Title))
|
||||
table.AddField(pr.HeadLabel(), tableprinter.WithColor(cs.Cyan))
|
||||
if !isTTY {
|
||||
table.AddField(prStateWithDraft(&pr))
|
||||
table.AddField(shared.PrStateWithDraft(&pr))
|
||||
}
|
||||
table.AddTimeField(opts.Now(), pr.CreatedAt, cs.Gray)
|
||||
table.EndRow()
|
||||
|
|
@ -234,11 +234,3 @@ func listRun(opts *ListOptions) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func prStateWithDraft(pr *api.PullRequest) string {
|
||||
if pr.IsDraft && pr.State == "OPEN" {
|
||||
return "DRAFT"
|
||||
}
|
||||
|
||||
return pr.State
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@ func StateTitleWithColor(cs *iostreams.ColorScheme, pr api.PullRequest) string {
|
|||
return prStateColorFunc(text.Title(pr.State))
|
||||
}
|
||||
|
||||
func PrStateWithDraft(pr *api.PullRequest) string {
|
||||
if pr.IsDraft && pr.State == "OPEN" {
|
||||
return "DRAFT"
|
||||
}
|
||||
|
||||
return pr.State
|
||||
}
|
||||
|
||||
func ColorForPRState(pr api.PullRequest) string {
|
||||
switch pr.State {
|
||||
case "OPEN":
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ func (s *PullRequestRefs) GetPRHeadLabel() string {
|
|||
}
|
||||
|
||||
func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, error) {
|
||||
// If we have a URL, we don't need git stuff
|
||||
if len(opts.Fields) == 0 {
|
||||
return nil, nil, errors.New("Find error: no fields specified")
|
||||
}
|
||||
|
|
@ -137,37 +138,74 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
|
|||
f.baseRefRepo = repo
|
||||
}
|
||||
|
||||
if f.prNumber == 0 && opts.Selector != "" {
|
||||
// If opts.Selector is a valid number then assume it is the
|
||||
// PR number unless opts.BaseBranch is specified. This is a
|
||||
// special case for PR create command which will always want
|
||||
// to assume that a numerical selector is a branch name rather
|
||||
// than PR number.
|
||||
prNumber, err := strconv.Atoi(strings.TrimPrefix(opts.Selector, "#"))
|
||||
if opts.BaseBranch == "" && err == nil {
|
||||
f.prNumber = prNumber
|
||||
} else {
|
||||
f.branchName = opts.Selector
|
||||
}
|
||||
} else {
|
||||
var prRefs PullRequestRefs
|
||||
if opts.Selector == "" {
|
||||
// You must be in a git repo for this case to work
|
||||
currentBranchName, err := f.branchFn()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
f.branchName = currentBranchName
|
||||
}
|
||||
|
||||
// Get the branch config for the current branchName
|
||||
branchConfig, err := f.branchConfig(f.branchName)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Get the branch config for the current branchName
|
||||
branchConfig, err := f.branchConfig(f.branchName)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Determine if the branch is configured to merge to a special PR ref
|
||||
prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`)
|
||||
if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil {
|
||||
prNumber, _ := strconv.Atoi(m[1])
|
||||
f.prNumber = prNumber
|
||||
// Determine if the branch is configured to merge to a special PR ref
|
||||
prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`)
|
||||
if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil {
|
||||
prNumber, _ := strconv.Atoi(m[1])
|
||||
f.prNumber = prNumber
|
||||
}
|
||||
|
||||
// Determine the PullRequestRefs from config
|
||||
if f.prNumber == 0 {
|
||||
rems, err := f.remotesFn()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Suppressing these errors as we have other means of computing the PullRequestRefs when these fail.
|
||||
parsedPushRevision, _ := f.parsePushRevision(f.branchName)
|
||||
|
||||
pushDefault, err := f.pushDefault()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
remotePushDefault, err := f.remotePushDefault()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
prRefs, err = ParsePRRefs(f.branchName, branchConfig, parsedPushRevision, pushDefault, remotePushDefault, f.baseRefRepo, rems)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
} else if f.prNumber == 0 {
|
||||
// You gave me a selector but I couldn't find a PR number (it wasn't a URL)
|
||||
|
||||
// Try to get a PR number from the selector
|
||||
prNumber, err := strconv.Atoi(strings.TrimPrefix(opts.Selector, "#"))
|
||||
// If opts.Selector is a valid number then assume it is the
|
||||
// PR number unless opts.BaseBranch is specified. This is a
|
||||
// special case for PR create command which will always want
|
||||
// to assume that a numerical selector is a branch name rather
|
||||
// than PR number.
|
||||
if opts.BaseBranch == "" && err == nil {
|
||||
f.prNumber = prNumber
|
||||
} else {
|
||||
f.branchName = opts.Selector
|
||||
// We don't expect an error here because parsedPushRevision is empty
|
||||
prRefs, err = ParsePRRefs(f.branchName, git.BranchConfig{}, "", "", "", f.baseRefRepo, remotes.Remotes{})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up HTTP client
|
||||
|
|
@ -217,29 +255,6 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
|
|||
return pr, f.baseRefRepo, err
|
||||
}
|
||||
} else {
|
||||
rems, err := f.remotesFn()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pushDefault, err := f.pushDefault()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Suppressing these errors as we have other means of computing the PullRequestRefs when these fail.
|
||||
parsedPushRevision, _ := f.parsePushRevision(f.branchName)
|
||||
|
||||
remotePushDefault, err := f.remotePushDefault()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
prRefs, err := ParsePRRefs(f.branchName, branchConfig, parsedPushRevision, pushDefault, remotePushDefault, f.baseRefRepo, rems)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pr, err = findForBranch(httpClient, f.baseRefRepo, opts.BaseBranch, prRefs.GetPRHeadLabel(), opts.States, fields.ToSlice())
|
||||
if err != nil {
|
||||
return pr, f.baseRefRepo, err
|
||||
|
|
|
|||
|
|
@ -211,6 +211,38 @@ func TestFind(t *testing.T) {
|
|||
wantPR: 13,
|
||||
wantRepo: "https://example.org/OWNER/REPO",
|
||||
},
|
||||
{
|
||||
name: "PR URL argument and not in a local git repo",
|
||||
args: args{
|
||||
selector: "https://example.org/OWNER/REPO/pull/13/files",
|
||||
fields: []string{"id", "number"},
|
||||
baseRepoFn: nil,
|
||||
branchFn: func() (string, error) {
|
||||
return "", &git.GitError{
|
||||
Stderr: "fatal: branchFn error",
|
||||
ExitCode: 128,
|
||||
}
|
||||
},
|
||||
branchConfig: stubBranchConfig(git.BranchConfig{}, &git.GitError{
|
||||
Stderr: "fatal: branchConfig error",
|
||||
ExitCode: 128,
|
||||
}),
|
||||
pushDefault: stubPushDefault("simple", nil),
|
||||
remotePushDefault: stubRemotePushDefault("", &git.GitError{
|
||||
Stderr: "fatal: remotePushDefault error",
|
||||
ExitCode: 128,
|
||||
}),
|
||||
},
|
||||
httpStub: func(r *httpmock.Registry) {
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query PullRequestByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{
|
||||
"pullRequest":{"number":13}
|
||||
}}}`))
|
||||
},
|
||||
wantPR: 13,
|
||||
wantRepo: "https://example.org/OWNER/REPO",
|
||||
},
|
||||
{
|
||||
name: "when provided branch argument with an open and closed PR for that branch name, it returns the open PR",
|
||||
args: args{
|
||||
|
|
|
|||
196
pkg/cmd/pr/shared/lister.go
Normal file
196
pkg/cmd/pr/shared/lister.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
|
||||
api "github.com/cli/cli/v2/api"
|
||||
)
|
||||
|
||||
type PRLister interface {
|
||||
List(opt ListOptions) (*api.PullRequestAndTotalCount, error)
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
LimitResults int
|
||||
|
||||
State string
|
||||
BaseBranch string
|
||||
HeadBranch string
|
||||
|
||||
Fields []string
|
||||
}
|
||||
|
||||
type lister struct {
|
||||
baseRepoFn func() (ghrepo.Interface, error)
|
||||
httpClient func() (*http.Client, error)
|
||||
}
|
||||
|
||||
func NewLister(factory *cmdutil.Factory) PRLister {
|
||||
return &lister{
|
||||
baseRepoFn: factory.BaseRepo,
|
||||
httpClient: factory.HttpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *lister) List(opts ListOptions) (*api.PullRequestAndTotalCount, error) {
|
||||
repo, err := l.baseRepoFn()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := l.httpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return listPullRequests(client, repo, opts)
|
||||
}
|
||||
|
||||
func listPullRequests(httpClient *http.Client, repo ghrepo.Interface, filters ListOptions) (*api.PullRequestAndTotalCount, error) {
|
||||
type response struct {
|
||||
Repository struct {
|
||||
PullRequests struct {
|
||||
Nodes []api.PullRequest
|
||||
PageInfo struct {
|
||||
HasNextPage bool
|
||||
EndCursor string
|
||||
}
|
||||
TotalCount int
|
||||
}
|
||||
}
|
||||
}
|
||||
limit := filters.LimitResults
|
||||
fragment := fmt.Sprintf("fragment pr on PullRequest{%s}", api.PullRequestGraphQL(filters.Fields))
|
||||
query := fragment + `
|
||||
query PullRequestList(
|
||||
$owner: String!,
|
||||
$repo: String!,
|
||||
$limit: Int!,
|
||||
$endCursor: String,
|
||||
$baseBranch: String,
|
||||
$headBranch: String,
|
||||
$state: [PullRequestState!] = OPEN
|
||||
) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(
|
||||
states: $state,
|
||||
baseRefName: $baseBranch,
|
||||
headRefName: $headBranch,
|
||||
first: $limit,
|
||||
after: $endCursor,
|
||||
orderBy: {field: CREATED_AT, direction: DESC}
|
||||
) {
|
||||
totalCount
|
||||
nodes {
|
||||
...pr
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
pageLimit := min(limit, 100)
|
||||
variables := map[string]interface{}{
|
||||
"owner": repo.RepoOwner(),
|
||||
"repo": repo.RepoName(),
|
||||
}
|
||||
|
||||
switch filters.State {
|
||||
case "open":
|
||||
variables["state"] = []string{"OPEN"}
|
||||
case "closed":
|
||||
variables["state"] = []string{"CLOSED", "MERGED"}
|
||||
case "merged":
|
||||
variables["state"] = []string{"MERGED"}
|
||||
case "all":
|
||||
variables["state"] = []string{"OPEN", "CLOSED", "MERGED"}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid state: %s", filters.State)
|
||||
}
|
||||
|
||||
if filters.BaseBranch != "" {
|
||||
variables["baseBranch"] = filters.BaseBranch
|
||||
}
|
||||
if filters.HeadBranch != "" {
|
||||
variables["headBranch"] = filters.HeadBranch
|
||||
}
|
||||
|
||||
res := api.PullRequestAndTotalCount{}
|
||||
var check = make(map[int]struct{})
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
loop:
|
||||
for {
|
||||
variables["limit"] = pageLimit
|
||||
var data response
|
||||
err := client.GraphQL(repo.RepoHost(), query, variables, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prData := data.Repository.PullRequests
|
||||
res.TotalCount = prData.TotalCount
|
||||
|
||||
for _, pr := range prData.Nodes {
|
||||
if _, exists := check[pr.Number]; exists && pr.Number > 0 {
|
||||
continue
|
||||
}
|
||||
check[pr.Number] = struct{}{}
|
||||
|
||||
res.PullRequests = append(res.PullRequests, pr)
|
||||
if len(res.PullRequests) == limit {
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
if prData.PageInfo.HasNextPage {
|
||||
variables["endCursor"] = prData.PageInfo.EndCursor
|
||||
pageLimit = min(pageLimit, limit-len(res.PullRequests))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
type mockLister struct {
|
||||
called bool
|
||||
expectFields []string
|
||||
|
||||
result *api.PullRequestAndTotalCount
|
||||
err error
|
||||
}
|
||||
|
||||
func NewMockLister(result *api.PullRequestAndTotalCount, err error) *mockLister {
|
||||
return &mockLister{
|
||||
result: result,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockLister) List(opt ListOptions) (*api.PullRequestAndTotalCount, error) {
|
||||
m.called = true
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
|
||||
if m.expectFields != nil {
|
||||
|
||||
if !isEqualSet(m.expectFields, opt.Fields) {
|
||||
return nil, fmt.Errorf("unexpected fields: %v", opt.Fields)
|
||||
}
|
||||
}
|
||||
|
||||
return m.result, m.err
|
||||
}
|
||||
|
||||
func (m *mockLister) ExpectFields(fields []string) {
|
||||
m.expectFields = fields
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ func statusRun(opts *StatusOptions) error {
|
|||
var currentHeadRefBranchName string
|
||||
|
||||
if !opts.HasRepoOverride {
|
||||
// We must be in a repo without the override
|
||||
currentBranchName, err = opts.Branch()
|
||||
if err != nil && !errors.Is(err, git.ErrNotOnAnyBranch) {
|
||||
return fmt.Errorf("could not query for pull request for current branch: %w", err)
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ func TestJSONProjectItem_DraftIssue_ProjectV2ItemFieldIterationValue(t *testing.
|
|||
iterationFieldValue := FieldValueNodes{Type: "ProjectV2ItemFieldIterationValue"}
|
||||
iterationFieldValue.ProjectV2ItemFieldIterationValue.Title = "Iteration Title"
|
||||
iterationFieldValue.ProjectV2ItemFieldIterationValue.Field = iterationField
|
||||
iterationFieldValue.ProjectV2ItemFieldIterationValue.IterationId = "iterationId"
|
||||
|
||||
draftIssue := ProjectItem{
|
||||
Id: "draftIssueId",
|
||||
|
|
@ -321,7 +322,7 @@ func TestJSONProjectItem_DraftIssue_ProjectV2ItemFieldIterationValue(t *testing.
|
|||
assert.NoError(t, err)
|
||||
assert.JSONEq(
|
||||
t,
|
||||
`{"items":[{"sprint":{"title":"Iteration Title","startDate":"","duration":0},"content":{"type":"DraftIssue","body":"a body","title":"Pull Request title","id":"draftIssueId"},"id":"draftIssueId"}],"totalCount":5}`,
|
||||
`{"items":[{"sprint":{"title":"Iteration Title","startDate":"","duration":0,"iterationId":"iterationId"},"content":{"type":"DraftIssue","body":"a body","title":"Pull Request title","id":"draftIssueId"},"id":"draftIssueId"}],"totalCount":5}`,
|
||||
string(out))
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -235,10 +235,11 @@ type FieldValueNodes struct {
|
|||
Field ProjectField
|
||||
} `graphql:"... on ProjectV2ItemFieldDateValue"`
|
||||
ProjectV2ItemFieldIterationValue struct {
|
||||
Title string
|
||||
StartDate string
|
||||
Duration int
|
||||
Field ProjectField
|
||||
Title string
|
||||
StartDate string
|
||||
Duration int
|
||||
Field ProjectField
|
||||
IterationId string
|
||||
} `graphql:"... on ProjectV2ItemFieldIterationValue"`
|
||||
ProjectV2ItemFieldLabelValue struct {
|
||||
Labels struct {
|
||||
|
|
@ -1488,9 +1489,10 @@ func projectFieldValueData(v FieldValueNodes) interface{} {
|
|||
return v.ProjectV2ItemFieldDateValue.Date
|
||||
case "ProjectV2ItemFieldIterationValue":
|
||||
return map[string]interface{}{
|
||||
"title": v.ProjectV2ItemFieldIterationValue.Title,
|
||||
"startDate": v.ProjectV2ItemFieldIterationValue.StartDate,
|
||||
"duration": v.ProjectV2ItemFieldIterationValue.Duration,
|
||||
"title": v.ProjectV2ItemFieldIterationValue.Title,
|
||||
"startDate": v.ProjectV2ItemFieldIterationValue.StartDate,
|
||||
"duration": v.ProjectV2ItemFieldIterationValue.Duration,
|
||||
"iterationId": v.ProjectV2ItemFieldIterationValue.IterationId,
|
||||
}
|
||||
case "ProjectV2ItemFieldNumberValue":
|
||||
return v.ProjectV2ItemFieldNumberValue.Number
|
||||
|
|
|
|||
|
|
@ -395,8 +395,9 @@ func TestProjectItems_FieldTitle(t *testing.T) {
|
|||
"fieldValues": map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{
|
||||
{
|
||||
"__typename": "ProjectV2ItemFieldIterationValue",
|
||||
"title": "Iteration Title 1",
|
||||
"__typename": "ProjectV2ItemFieldIterationValue",
|
||||
"title": "Iteration Title 1",
|
||||
"iterationId": "iterationId1",
|
||||
},
|
||||
{
|
||||
"__typename": "ProjectV2ItemFieldMilestoneValue",
|
||||
|
|
@ -426,6 +427,7 @@ func TestProjectItems_FieldTitle(t *testing.T) {
|
|||
assert.Len(t, project.Items.Nodes, 1)
|
||||
assert.Len(t, project.Items.Nodes[0].FieldValues.Nodes, 2)
|
||||
assert.Equal(t, project.Items.Nodes[0].FieldValues.Nodes[0].ProjectV2ItemFieldIterationValue.Title, "Iteration Title 1")
|
||||
assert.Equal(t, project.Items.Nodes[0].FieldValues.Nodes[0].ProjectV2ItemFieldIterationValue.IterationId, "iterationId1")
|
||||
assert.Equal(t, project.Items.Nodes[0].FieldValues.Nodes[1].ProjectV2ItemFieldMilestoneValue.Milestone.Title, "Milestone Title 1")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
46
pkg/cmd/repo/autolink/view/http.go
Normal file
46
pkg/cmd/repo/autolink/view/http.go
Normal file
|
|
@ -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
|
||||
}
|
||||
102
pkg/cmd/repo/autolink/view/http_test.go
Normal file
102
pkg/cmd/repo/autolink/view/http_test.go
Normal file
|
|
@ -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=<num>",
|
||||
"is_alphanumeric": true
|
||||
}`,
|
||||
expectedAutolink: &shared.Autolink{
|
||||
ID: 123,
|
||||
KeyPrefix: "TICKET-",
|
||||
URLTemplate: "https://example.com/TICKET?query=<num>",
|
||||
IsAlphanumeric: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "200 successful numeric view",
|
||||
id: "123",
|
||||
stubStatus: 200,
|
||||
stubRespJSON: `{
|
||||
"id": 123,
|
||||
"key_prefix": "TICKET-",
|
||||
"url_template": "https://example.com/TICKET?query=<num>",
|
||||
"is_alphanumeric": false
|
||||
}`,
|
||||
expectedAutolink: &shared.Autolink{
|
||||
ID: 123,
|
||||
KeyPrefix: "TICKET-",
|
||||
URLTemplate: "https://example.com/TICKET?query=<num>",
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
96
pkg/cmd/repo/autolink/view/view.go
Normal file
96
pkg/cmd/repo/autolink/view/view.go
Normal file
|
|
@ -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 <id>",
|
||||
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
|
||||
}
|
||||
197
pkg/cmd/repo/autolink/view/view_test.go
Normal file
197
pkg/cmd/repo/autolink/view/view_test.go
Normal file
|
|
@ -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=<num>",
|
||||
IsAlphanumeric: true,
|
||||
},
|
||||
},
|
||||
wantStdout: heredoc.Doc(`
|
||||
Autolink in OWNER/REPO
|
||||
|
||||
ID: 1
|
||||
Key Prefix: TICKET-
|
||||
URL Template: https://example.com/TICKET?query=<num>
|
||||
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=<num>",
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue