Merge branch 'trunk' into 9897-gh-cache-delete-exit-code

This commit is contained in:
Azeem Sajid 2025-02-06 23:00:49 +05:00 committed by GitHub
commit db8d548468
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 2650 additions and 864 deletions

View file

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

View file

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

View 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'

View file

@ -0,0 +1,45 @@
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
env FORK=${REPO}-fork
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository to act as upstream with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup of upstream
defer gh repo delete --yes ${ORG}/${REPO}
exec gh repo view ${ORG}/${REPO} --json id --jq '.id'
stdout2env REPO_ID
# Create a user fork of repository as opposed to private organization fork
exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK}
# Defer repo cleanup of fork
defer gh repo delete --yes ${ORG}/${FORK}
sleep 5
exec gh repo view ${ORG}/${FORK} --json id --jq '.id'
stdout2env FORK_ID
# Clone the repo
exec gh repo clone ${ORG}/${FORK}
cd ${FORK}
# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork
exec git checkout -b feature-branch upstream/main
exec git config branch.feature-branch.pushRemote origin
exec git commit --allow-empty -m 'Empty Commit'
exec git push
# Create the PR spanning upstream and fork repositories, gh pr create does not support headRepositoryId needed for private forks
exec gh api graphql -F repositoryId="${REPO_ID}" -F headRepositoryId="${FORK_ID}" -F query='mutation CreatePullRequest($headRepositoryId: ID!, $repositoryId: ID!) { createPullRequest(input:{ baseRefName: "main", body: "Feature Body", draft: false, headRefName: "feature-branch", headRepositoryId: $headRepositoryId, repositoryId: $repositoryId, title:"Feature Title" }){ pullRequest{ id url } } }'
# View the PR
exec gh pr view
stdout 'Feature Title'
# Check the PR status
env PR_STATUS_BRANCH=#1 Feature Title [${ORG}:feature-branch]
exec gh pr status
stdout $PR_STATUS_BRANCH

View file

@ -0,0 +1,38 @@
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# 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}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
cd ${REPO}
# Configure default push behavior so local and remote branches will be the same
exec git config push.default current
# Prepare a branch where changes are pulled from the default branch instead of remote branch of same name
exec git checkout -b feature-branch
exec git branch --set-upstream-to origin/main
exec git rev-parse --abbrev-ref feature-branch@{upstream}
stdout origin/main
# Create the PR
exec git commit --allow-empty -m 'Empty Commit'
exec git push
exec gh pr create -B main -H feature-branch --title 'Feature Title' --body 'Feature Body'
# View the PR
exec gh pr view
stdout 'Feature Title'
# Check the PR status
env PR_STATUS_BRANCH=#1 Feature Title [feature-branch]
exec gh pr status
stdout $PR_STATUS_BRANCH

View file

@ -0,0 +1,45 @@
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
env FORK=${REPO}-fork
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository to act as upstream with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup of upstream
defer gh repo delete --yes ${ORG}/${REPO}
exec gh repo view ${ORG}/${REPO} --json id --jq '.id'
stdout2env REPO_ID
# Create a user fork of repository as opposed to private organization fork
exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK}
# Defer repo cleanup of fork
defer gh repo delete --yes ${ORG}/${FORK}
sleep 5
exec gh repo view ${ORG}/${FORK} --json id --jq '.id'
stdout2env FORK_ID
# Clone the repo
exec gh repo clone ${ORG}/${FORK}
cd ${FORK}
# Prepare a branch where changes are pulled from the upstream default branch but pushed to fork
exec git checkout -b feature-branch upstream/main
exec git config remote.pushDefault origin
exec git commit --allow-empty -m 'Empty Commit'
exec git push
# Create the PR spanning upstream and fork repositories, gh pr create does not support headRepositoryId needed for private forks
exec gh api graphql -F repositoryId="${REPO_ID}" -F headRepositoryId="${FORK_ID}" -F query='mutation CreatePullRequest($headRepositoryId: ID!, $repositoryId: ID!) { createPullRequest(input:{ baseRefName: "main", body: "Feature Body", draft: false, headRefName: "feature-branch", headRepositoryId: $headRepositoryId, repositoryId: $repositoryId, title:"Feature Title" }){ pullRequest{ id url } } }'
# View the PR
exec gh pr view
stdout 'Feature Title'
# Check the PR status
env PR_STATUS_BRANCH=#1 Feature Title [${ORG}:feature-branch]
exec gh pr status
stdout $PR_STATUS_BRANCH

View file

@ -0,0 +1,35 @@
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# 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}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
cd ${REPO}
# Configure default push behavior so local and remote branches have to be the same
exec git config push.default simple
# Prepare a branch where changes are pulled from the default branch instead of remote branch of same name
exec git checkout -b feature-branch origin/main
# Create the PR
exec git commit --allow-empty -m 'Empty Commit'
exec git push origin feature-branch
exec gh pr create -H feature-branch --title 'Feature Title' --body 'Feature Body'
# View the PR
exec gh pr view
stdout 'Feature Title'
# Check the PR status
env PR_STATUS_BRANCH=#1 Feature Title [feature-branch]
exec gh pr status
stdout $PR_STATUS_BRANCH

View file

@ -0,0 +1,9 @@
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
# Attempt to rename the repo with a slash in the name
! exec gh repo rename $ORG/new-name --repo=$ORG/$SCRIPT_NAME-$RANDOM_STRING --yes
stderr 'New repository name cannot contain '/' character - to transfer a repository to a new owner, you must follow additional steps on GitHub.com. For more information on transferring repository ownership, see <https://docs.github.com/en/repositories/creating-and-managing-repositories/transferring-a-repository>.'
# Defer repo deletion
defer gh repo delete $ORG/$SCRIPT_NAME-$RANDOM_STRING --yes

View file

@ -376,20 +376,20 @@ func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte,
return out, nil
}
// ReadBranchConfig parses the `branch.BRANCH.(remote|merge|gh-merge-base)` part of git config.
// ReadBranchConfig parses the `branch.BRANCH.(remote|merge|pushremote|gh-merge-base)` part of git config.
// If no branch config is found or there is an error in the command, it returns an empty BranchConfig.
// Downstream consumers of ReadBranchConfig should consider the behavior they desire if this errors,
// as an empty config is not necessarily breaking.
func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (BranchConfig, error) {
prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch))
args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge|%s)$", prefix, MergeBaseConfig)}
args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge|pushremote|%s)$", prefix, MergeBaseConfig)}
cmd, err := c.Command(ctx, args...)
if err != nil {
return BranchConfig{}, err
}
out, err := cmd.Output()
branchCfgOut, err := cmd.Output()
if err != nil {
// This is the error we expect if the git command does not run successfully.
// If the ExitCode is 1, then we just didn't find any config for the branch.
@ -400,13 +400,14 @@ func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (BranchCon
return BranchConfig{}, nil
}
return parseBranchConfig(outputLines(out)), nil
return parseBranchConfig(outputLines(branchCfgOut)), nil
}
func parseBranchConfig(configLines []string) BranchConfig {
func parseBranchConfig(branchConfigLines []string) BranchConfig {
var cfg BranchConfig
for _, line := range configLines {
// Read the config lines for the specific branch
for _, line := range branchConfigLines {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
@ -414,21 +415,16 @@ func parseBranchConfig(configLines []string) BranchConfig {
keys := strings.Split(parts[0], ".")
switch keys[len(keys)-1] {
case "remote":
if strings.Contains(parts[1], ":") {
u, err := ParseURL(parts[1])
if err != nil {
continue
}
cfg.RemoteURL = u
} else if !isFilesystemPath(parts[1]) {
cfg.RemoteName = parts[1]
}
cfg.RemoteURL, cfg.RemoteName = parseRemoteURLOrName(parts[1])
case "pushremote":
cfg.PushRemoteURL, cfg.PushRemoteName = parseRemoteURLOrName(parts[1])
case "merge":
cfg.MergeRef = parts[1]
case MergeBaseConfig:
cfg.MergeBase = parts[1]
}
}
return cfg
}
@ -445,6 +441,47 @@ func (c *Client) SetBranchConfig(ctx context.Context, branch, name, value string
return err
}
// PushDefault returns the value of push.default in the config. If the value
// is not set, it returns "simple" (the default git value). See
// https://git-scm.com/docs/git-config#Documentation/git-config.txt-pushdefault
func (c *Client) PushDefault(ctx context.Context) (string, error) {
pushDefault, err := c.Config(ctx, "push.default")
if err == nil {
return pushDefault, nil
}
var gitError *GitError
if ok := errors.As(err, &gitError); ok && gitError.ExitCode == 1 {
return "simple", nil
}
return "", err
}
// RemotePushDefault returns the value of remote.pushDefault in the config. If
// the value is not set, it returns an empty string.
func (c *Client) RemotePushDefault(ctx context.Context) (string, error) {
remotePushDefault, err := c.Config(ctx, "remote.pushDefault")
if err == nil {
return remotePushDefault, nil
}
var gitError *GitError
if ok := errors.As(err, &gitError); ok && gitError.ExitCode == 1 {
return "", nil
}
return "", err
}
// ParsePushRevision gets the value of the @{push} revision syntax
// An error here doesn't necessarily mean something is broken, but may mean that the @{push}
// revision syntax couldn't be resolved, such as in non-centralized workflows with
// push.default = simple. Downstream consumers should consider how to handle this error.
func (c *Client) ParsePushRevision(ctx context.Context, branch string) (string, error) {
revParseOut, err := c.revParse(ctx, "--abbrev-ref", branch+"@{push}")
return firstLine(revParseOut), err
}
func (c *Client) DeleteLocalTag(ctx context.Context, tag string) error {
args := []string{"tag", "-d", tag}
cmd, err := c.Command(ctx, args...)
@ -790,6 +827,17 @@ func parseRemotes(remotesStr []string) RemoteSet {
return remotes
}
func parseRemoteURLOrName(value string) (*url.URL, string) {
if strings.Contains(value, ":") {
if u, err := ParseURL(value); err == nil {
return u, ""
}
} else if !isFilesystemPath(value) {
return nil, value
}
return nil, ""
}
func populateResolvedRemotes(remotes RemoteSet, resolved []string) {
for _, l := range resolved {
parts := strings.SplitN(l, " ", 2)

View file

@ -14,6 +14,7 @@ import (
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -728,56 +729,76 @@ func TestClientCommitBody(t *testing.T) {
func TestClientReadBranchConfig(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
cmds mockedCommands
branch string
wantBranchConfig BranchConfig
wantError *GitError
}{
{
name: "read branch config",
cmdExitStatus: 0,
cmdStdout: "branch.trunk.remote origin\nbranch.trunk.merge refs/heads/trunk\nbranch.trunk.gh-merge-base trunk",
branch: "trunk",
wantBranchConfig: BranchConfig{RemoteName: "origin", MergeRef: "refs/heads/trunk", MergeBase: "trunk"},
wantError: nil,
},
{
name: "git config runs successfully but returns no output (Exit Code 1)",
cmdExitStatus: 1,
cmdStdout: "",
cmdStderr: "",
name: "when the git config has no (remote|merge|pushremote|gh-merge-base) keys, it should return an empty BranchConfig and no error",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
ExitStatus: 1,
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{},
wantError: nil,
},
{
name: "output error (Exit Code > 1)",
cmdExitStatus: 2,
cmdStdout: "",
cmdStderr: "git error message",
name: "when the git fails to read the config, it should return an empty BranchConfig and the error",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
ExitStatus: 2,
Stderr: "git error",
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{},
wantError: &GitError{},
wantError: &GitError{
ExitCode: 2,
Stderr: "git error",
},
},
{
name: "when the config is read, it should return the correct BranchConfig",
cmds: mockedCommands{
`path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|pushremote|gh-merge-base)$`: {
Stdout: heredoc.Doc(`
branch.trunk.remote upstream
branch.trunk.merge refs/heads/trunk
branch.trunk.pushremote origin
branch.trunk.gh-merge-base gh-merge-base
`),
},
},
branch: "trunk",
wantBranchConfig: BranchConfig{
RemoteName: "upstream",
PushRemoteName: "origin",
MergeRef: "refs/heads/trunk",
MergeBase: "gh-merge-base",
},
wantError: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
cmdCtx := createMockedCommandContext(t, tt.cmds)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
branchConfig, err := client.ReadBranchConfig(context.Background(), tt.branch)
wantCmdArgs := fmt.Sprintf("path/to/git config --get-regexp ^branch\\.%s\\.(remote|merge|gh-merge-base)$", tt.branch)
assert.Equal(t, wantCmdArgs, strings.Join(cmd.Args[3:], " "))
assert.Equal(t, tt.wantBranchConfig, branchConfig)
if tt.wantError != nil {
assert.ErrorAs(t, err, &tt.wantError)
var gitError *GitError
require.ErrorAs(t, err, &gitError)
assert.Equal(t, tt.wantError.ExitCode, gitError.ExitCode)
assert.Equal(t, tt.wantError.Stderr, gitError.Stderr)
} else {
assert.NoError(t, err)
require.NoError(t, err)
}
assert.Equal(t, tt.wantBranchConfig, branchConfig)
})
}
}
@ -810,44 +831,297 @@ func Test_parseBranchConfig(t *testing.T) {
},
},
{
name: "remote, merge ref, and merge base all specified",
configLines: []string{
"branch.trunk.remote origin",
"branch.trunk.merge refs/heads/trunk",
"branch.trunk.gh-merge-base gh-merge-base",
},
name: "pushremote",
configLines: []string{"branch.trunk.pushremote pushremote"},
wantBranchConfig: BranchConfig{
RemoteName: "origin",
MergeRef: "refs/heads/trunk",
MergeBase: "gh-merge-base",
PushRemoteName: "pushremote",
},
},
{
name: "remote URL",
name: "remote and pushremote are specified by name",
configLines: []string{
"branch.Frederick888/main.remote git@github.com:Frederick888/playground.git",
"branch.Frederick888/main.merge refs/heads/main",
"branch.trunk.remote upstream",
"branch.trunk.pushremote origin",
},
wantBranchConfig: BranchConfig{
RemoteName: "upstream",
PushRemoteName: "origin",
},
},
{
name: "remote and pushremote are specified by url",
configLines: []string{
"branch.trunk.remote git@github.com:UPSTREAMOWNER/REPO.git",
"branch.trunk.pushremote git@github.com:ORIGINOWNER/REPO.git",
},
wantBranchConfig: BranchConfig{
MergeRef: "refs/heads/main",
RemoteURL: &url.URL{
Scheme: "ssh",
User: url.User("git"),
Host: "github.com",
Path: "/Frederick888/playground.git",
Path: "/UPSTREAMOWNER/REPO.git",
},
PushRemoteURL: &url.URL{
Scheme: "ssh",
User: url.User("git"),
Host: "github.com",
Path: "/ORIGINOWNER/REPO.git",
},
},
},
{
name: "remote, pushremote, gh-merge-base, and merge ref all specified",
configLines: []string{
"branch.trunk.remote remote",
"branch.trunk.pushremote pushremote",
"branch.trunk.gh-merge-base gh-merge-base",
"branch.trunk.merge refs/heads/trunk",
},
wantBranchConfig: BranchConfig{
RemoteName: "remote",
PushRemoteName: "pushremote",
MergeBase: "gh-merge-base",
MergeRef: "refs/heads/trunk",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
branchConfig := parseBranchConfig(tt.configLines)
assert.Equal(t, tt.wantBranchConfig.RemoteName, branchConfig.RemoteName)
assert.Equal(t, tt.wantBranchConfig.MergeRef, branchConfig.MergeRef)
assert.Equal(t, tt.wantBranchConfig.MergeBase, branchConfig.MergeBase)
assert.Equalf(t, tt.wantBranchConfig.RemoteName, branchConfig.RemoteName, "unexpected RemoteName")
assert.Equalf(t, tt.wantBranchConfig.MergeRef, branchConfig.MergeRef, "unexpected MergeRef")
assert.Equalf(t, tt.wantBranchConfig.MergeBase, branchConfig.MergeBase, "unexpected MergeBase")
assert.Equalf(t, tt.wantBranchConfig.PushRemoteName, branchConfig.PushRemoteName, "unexpected PushRemoteName")
if tt.wantBranchConfig.RemoteURL != nil {
assert.Equal(t, tt.wantBranchConfig.RemoteURL.String(), branchConfig.RemoteURL.String())
assert.Equalf(t, tt.wantBranchConfig.RemoteURL.String(), branchConfig.RemoteURL.String(), "unexpected RemoteURL")
}
if tt.wantBranchConfig.PushRemoteURL != nil {
assert.Equalf(t, tt.wantBranchConfig.PushRemoteURL.String(), branchConfig.PushRemoteURL.String(), "unexpected PushRemoteURL")
}
})
}
}
func Test_parseRemoteURLOrName(t *testing.T) {
tests := []struct {
name string
value string
wantRemoteURL *url.URL
wantRemoteName string
}{
{
name: "empty value",
value: "",
wantRemoteURL: nil,
wantRemoteName: "",
},
{
name: "remote URL",
value: "git@github.com:foo/bar.git",
wantRemoteURL: &url.URL{
Scheme: "ssh",
User: url.User("git"),
Host: "github.com",
Path: "/foo/bar.git",
},
wantRemoteName: "",
},
{
name: "remote name",
value: "origin",
wantRemoteURL: nil,
wantRemoteName: "origin",
},
{
name: "remote name is from filesystem",
value: "./path/to/repo",
wantRemoteURL: nil,
wantRemoteName: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
remoteURL, remoteName := parseRemoteURLOrName(tt.value)
assert.Equal(t, tt.wantRemoteURL, remoteURL)
assert.Equal(t, tt.wantRemoteName, remoteName)
})
}
}
func TestClientPushDefault(t *testing.T) {
tests := []struct {
name string
commandResult commandResult
wantPushDefault string
wantError *GitError
}{
{
name: "push default is not set",
commandResult: commandResult{
ExitStatus: 1,
Stderr: "error: key does not contain a section: remote.pushDefault",
},
wantPushDefault: "simple",
wantError: nil,
},
{
name: "push default is set to current",
commandResult: commandResult{
ExitStatus: 0,
Stdout: "current",
},
wantPushDefault: "current",
wantError: nil,
},
{
name: "push default errors",
commandResult: commandResult{
ExitStatus: 128,
Stderr: "fatal: git error",
},
wantPushDefault: "",
wantError: &GitError{
ExitCode: 128,
Stderr: "fatal: git error",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmdCtx := createMockedCommandContext(t, mockedCommands{
`path/to/git config push.default`: tt.commandResult,
},
)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
pushDefault, err := client.PushDefault(context.Background())
if tt.wantError != nil {
var gitError *GitError
require.ErrorAs(t, err, &gitError)
assert.Equal(t, tt.wantError.ExitCode, gitError.ExitCode)
assert.Equal(t, tt.wantError.Stderr, gitError.Stderr)
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantPushDefault, pushDefault)
})
}
}
func TestClientRemotePushDefault(t *testing.T) {
tests := []struct {
name string
commandResult commandResult
wantRemotePushDefault string
wantError *GitError
}{
{
name: "remote.pushDefault is not set",
commandResult: commandResult{
ExitStatus: 1,
Stderr: "error: key does not contain a section: remote.pushDefault",
},
wantRemotePushDefault: "",
wantError: nil,
},
{
name: "remote.pushDefault is set to origin",
commandResult: commandResult{
ExitStatus: 0,
Stdout: "origin",
},
wantRemotePushDefault: "origin",
wantError: nil,
},
{
name: "remote.pushDefault errors",
commandResult: commandResult{
ExitStatus: 128,
Stderr: "fatal: git error",
},
wantRemotePushDefault: "",
wantError: &GitError{
ExitCode: 128,
Stderr: "fatal: git error",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmdCtx := createMockedCommandContext(t, mockedCommands{
`path/to/git config remote.pushDefault`: tt.commandResult,
},
)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
pushDefault, err := client.RemotePushDefault(context.Background())
if tt.wantError != nil {
var gitError *GitError
require.ErrorAs(t, err, &gitError)
assert.Equal(t, tt.wantError.ExitCode, gitError.ExitCode)
assert.Equal(t, tt.wantError.Stderr, gitError.Stderr)
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantRemotePushDefault, pushDefault)
})
}
}
func TestClientParsePushRevision(t *testing.T) {
tests := []struct {
name string
branch string
commandResult commandResult
wantParsedPushRevision string
wantError *GitError
}{
{
name: "@{push} resolves to origin/branchName",
branch: "branchName",
commandResult: commandResult{
ExitStatus: 0,
Stdout: "origin/branchName",
},
wantParsedPushRevision: "origin/branchName",
},
{
name: "@{push} doesn't resolve",
commandResult: commandResult{
ExitStatus: 128,
Stderr: "fatal: git error",
},
wantParsedPushRevision: "",
wantError: &GitError{
ExitCode: 128,
Stderr: "fatal: git error",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := fmt.Sprintf("path/to/git rev-parse --abbrev-ref %s@{push}", tt.branch)
cmdCtx := createMockedCommandContext(t, mockedCommands{
args(cmd): tt.commandResult,
})
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
pushDefault, err := client.ParsePushRevision(context.Background(), tt.branch)
if tt.wantError != nil {
var gitError *GitError
require.ErrorAs(t, err, &gitError)
assert.Equal(t, tt.wantError.ExitCode, gitError.ExitCode)
assert.Equal(t, tt.wantError.Stderr, gitError.Stderr)
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantParsedPushRevision, pushDefault)
})
}
}
@ -1601,13 +1875,17 @@ func TestCommandMocking(t *testing.T) {
jsonVar, ok := os.LookupEnv("GH_HELPER_PROCESS_RICH_COMMANDS")
if !ok {
fmt.Fprint(os.Stderr, "missing GH_HELPER_PROCESS_RICH_COMMANDS")
os.Exit(1)
// Exit 1 is used for empty key values in the git config. This is non-breaking in those use cases,
// so this is returning a non-zero exit code to avoid suppressing this error for those use cases.
os.Exit(16)
}
var commands mockedCommands
if err := json.Unmarshal([]byte(jsonVar), &commands); err != nil {
fmt.Fprint(os.Stderr, "failed to unmarshal GH_HELPER_PROCESS_RICH_COMMANDS")
os.Exit(1)
// Exit 1 is used for empty key values in the git config. This is non-breaking in those use cases,
// so this is returning a non-zero exit code to avoid suppressing this error for those use cases.
os.Exit(16)
}
// The discarded args are those for the go test binary itself, e.g. `-test.run=TestHelperProcessRich`
@ -1616,7 +1894,9 @@ func TestCommandMocking(t *testing.T) {
commandResult, ok := commands[args(strings.Join(realArgs, " "))]
if !ok {
fmt.Fprintf(os.Stderr, "unexpected command: %s\n", strings.Join(realArgs, " "))
os.Exit(1)
// Exit 1 is used for empty key values in the git config. This is non-breaking in those use cases,
// so this is returning a non-zero exit code to avoid suppressing this error for those use cases.
os.Exit(16)
}
if commandResult.Stdout != "" {

104
git/command_test.go Normal file
View 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)
}

View file

@ -60,10 +60,14 @@ type Commit struct {
Body string
}
// These are the keys we read from the git branch.<name> config.
type BranchConfig struct {
RemoteName string
RemoteURL *url.URL
RemoteName string // .remote if string
RemoteURL *url.URL // .remote if url
MergeRef string // .merge
PushRemoteName string // .pushremote if string
PushRemoteURL *url.URL // .pushremote if url
// MergeBase is the optional base branch to target in a new PR if `--base` is not specified.
MergeBase string
MergeRef string
}

2
go.mod
View file

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

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

View file

@ -142,7 +142,8 @@ func genMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string)
fmt.Fprintf(w, "```\n%s\n```\n\n", cmd.UseLine())
}
if hasLong {
fmt.Fprintf(w, "%s\n\n", cmd.Long)
longWithEscapedPipe := strings.ReplaceAll(cmd.Long, "|", "&#124;")
fmt.Fprintf(w, "%s\n\n", longWithEscapedPipe)
}
for _, g := range root.GroupedCommands(cmd) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ import (
func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
longDoc := strings.Builder{}
longDoc.WriteString("Display or change configuration settings for gh.\n\n")
longDoc.WriteString("Current respected settings:\n")
longDoc.WriteString("Current respected settings:\n\n")
for _, co := range config.Options {
longDoc.WriteString(fmt.Sprintf("- `%s`: %s", co.Key, co.Description))
if len(co.AllowedValues) > 0 {

View file

@ -298,9 +298,18 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
Long: heredoc.Docf(`
Install a GitHub CLI extension from a GitHub or local repository.
For GitHub repositories, the repository argument can be specified in %[1]sOWNER/REPO%[1]s format or as a full repository URL.
For GitHub repositories, the repository argument can be specified in
%[1]sOWNER/REPO%[1]s format or as a full repository URL.
The URL format is useful when the repository is not hosted on github.com.
For remote repositories, the GitHub CLI first looks for the release artifacts assuming
that it's a binary extension i.e. prebuilt binaries provided as part of the release.
In the absence of a release, the repository itself is cloned assuming that it's a
script extension i.e. prebuilt executable or script exists on its root.
The %[1]s--pin%[1]s flag may be used to specify a tag or commit for binary and script
extensions respectively, the latest version is used otherwise.
For local repositories, often used while developing extensions, use %[1]s.%[1]s as the
value of the repository argument. Note the following:

View file

@ -268,7 +268,7 @@ func (m *Manager) Install(repo ghrepo.Interface, target string) error {
return err
}
if !hs {
return errors.New("extension is not installable: missing executable")
return fmt.Errorf("extension is not installable: no usable release artifact or script found in %s", repo)
}
return m.installGit(repo, target)

View file

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

View file

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

View file

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

View file

@ -518,6 +518,7 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, u
return nil
}
// TODO: Replace with the finder's PullRequestRefs struct
// trackingRef represents a ref for a remote tracking branch.
type trackingRef struct {
remoteName string
@ -685,7 +686,9 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
return nil, err
}
if isPushEnabled {
// determine whether the head branch is already pushed to a remote
// TODO: This doesn't respect the @{push} revision resolution or triagular workflows assembled with
// remote.pushDefault, or branch.<branchName>.pushremote config settings. The finder's ParsePRRefs
// may be able to replace this function entirely.
if trackingRef, found := tryDetermineTrackingRef(gitClient, remotes, headBranch, headBranchConfig); found {
isPushEnabled = false
if r, err := remotes.FindByName(trackingRef.remoteName); err == nil {

View file

@ -1515,7 +1515,7 @@ func Test_createRun(t *testing.T) {
},
customBranchConfig: true,
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp \^branch\\\.task1\\\.\(remote\|merge\|gh-merge-base\)\$`, 0, heredoc.Doc(`
cs.Register(`git config --get-regexp \^branch\\\.task1\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, heredoc.Doc(`
branch.task1.remote origin
branch.task1.merge refs/heads/task1
branch.task1.gh-merge-base feature/feat2`)) // ReadBranchConfig
@ -1549,7 +1549,7 @@ func Test_createRun(t *testing.T) {
defer cmdTeardown(t)
cs.Register(`git status --porcelain`, 0, "")
if !tt.customBranchConfig {
cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|gh-merge-base\)\$`, 0, "")
cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
}
if tt.cmdStubs != nil {

View file

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

View file

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

View file

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

View file

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

View file

@ -33,16 +33,19 @@ type progressIndicator interface {
}
type finder struct {
baseRepoFn func() (ghrepo.Interface, error)
branchFn func() (string, error)
remotesFn func() (remotes.Remotes, error)
httpClient func() (*http.Client, error)
branchConfig func(string) (git.BranchConfig, error)
progress progressIndicator
baseRepoFn func() (ghrepo.Interface, error)
branchFn func() (string, error)
remotesFn func() (remotes.Remotes, error)
httpClient func() (*http.Client, error)
pushDefault func() (string, error)
remotePushDefault func() (string, error)
parsePushRevision func(string) (string, error)
branchConfig func(string) (git.BranchConfig, error)
progress progressIndicator
repo ghrepo.Interface
prNumber int
branchName string
baseRefRepo ghrepo.Interface
prNumber int
branchName string
}
func NewFinder(factory *cmdutil.Factory) PRFinder {
@ -57,7 +60,16 @@ func NewFinder(factory *cmdutil.Factory) PRFinder {
branchFn: factory.Branch,
remotesFn: factory.Remotes,
httpClient: factory.HttpClient,
progress: factory.IOStreams,
pushDefault: func() (string, error) {
return factory.GitClient.PushDefault(context.Background())
},
remotePushDefault: func() (string, error) {
return factory.GitClient.RemotePushDefault(context.Background())
},
parsePushRevision: func(branch string) (string, error) {
return factory.GitClient.ParsePushRevision(context.Background(), branch)
},
progress: factory.IOStreams,
branchConfig: func(s string) (git.BranchConfig, error) {
return factory.GitClient.ReadBranchConfig(context.Background(), s)
},
@ -85,46 +97,118 @@ type FindOptions struct {
States []string
}
// TODO: Does this also need the BaseBranchName?
// PR's are represented by the following:
// baseRef -----PR-----> headRef
//
// A ref is described as "remoteName/branchName", so
// baseRepoName/baseBranchName -----PR-----> headRepoName/headBranchName
type PullRequestRefs struct {
BranchName string
HeadRepo ghrepo.Interface
BaseRepo ghrepo.Interface
}
// GetPRHeadLabel returns the string that the GitHub API uses to identify the PR. This is
// either just the branch name or, if the PR is originating from a fork, the fork owner
// and the branch name, like <owner>:<branch>.
func (s *PullRequestRefs) GetPRHeadLabel() string {
if ghrepo.IsSame(s.HeadRepo, s.BaseRepo) {
return s.BranchName
}
return fmt.Sprintf("%s:%s", s.HeadRepo.RepoOwner(), s.BranchName)
}
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")
}
if repo, prNumber, err := f.parseURL(opts.Selector); err == nil {
f.prNumber = prNumber
f.repo = repo
f.baseRefRepo = repo
}
if f.repo == nil {
if f.baseRefRepo == nil {
repo, err := f.baseRepoFn()
if err != nil {
return nil, nil, err
}
f.repo = repo
f.baseRefRepo = repo
}
var prRefs PullRequestRefs
if opts.Selector == "" {
if branch, prNumber, err := f.parseCurrentBranch(); err != nil {
// You must be in a git repo for this case to work
currentBranchName, err := f.branchFn()
if err != nil {
return nil, nil, err
} else if prNumber > 0 {
f.prNumber = prNumber
} else {
f.branchName = branch
}
f.branchName = currentBranchName
// 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 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.
prNumber, err := strconv.Atoi(strings.TrimPrefix(opts.Selector, "#"))
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
httpClient, err := f.httpClient()
if err != nil {
return nil, nil, err
@ -143,7 +227,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
if fields.Contains("isInMergeQueue") || fields.Contains("isMergeQueueEnabled") {
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
detector := fd.NewDetector(cachedClient, f.repo.RepoHost())
detector := fd.NewDetector(cachedClient, f.baseRefRepo.RepoHost())
prFeatures, err := detector.PullRequestFeatures()
if err != nil {
return nil, nil, err
@ -164,36 +248,39 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
if f.prNumber > 0 {
if numberFieldOnly {
// avoid hitting the API if we already have all the information
return &api.PullRequest{Number: f.prNumber}, f.repo, nil
return &api.PullRequest{Number: f.prNumber}, f.baseRefRepo, nil
}
pr, err = findByNumber(httpClient, f.baseRefRepo, f.prNumber, fields.ToSlice())
if err != nil {
return pr, f.baseRefRepo, err
}
pr, err = findByNumber(httpClient, f.repo, f.prNumber, fields.ToSlice())
} else {
pr, err = findForBranch(httpClient, f.repo, opts.BaseBranch, f.branchName, opts.States, fields.ToSlice())
}
if err != nil {
return pr, f.repo, err
pr, err = findForBranch(httpClient, f.baseRefRepo, opts.BaseBranch, prRefs.GetPRHeadLabel(), opts.States, fields.ToSlice())
if err != nil {
return pr, f.baseRefRepo, err
}
}
g, _ := errgroup.WithContext(context.Background())
if fields.Contains("reviews") {
g.Go(func() error {
return preloadPrReviews(httpClient, f.repo, pr)
return preloadPrReviews(httpClient, f.baseRefRepo, pr)
})
}
if fields.Contains("comments") {
g.Go(func() error {
return preloadPrComments(httpClient, f.repo, pr)
return preloadPrComments(httpClient, f.baseRefRepo, pr)
})
}
if fields.Contains("statusCheckRollup") {
g.Go(func() error {
return preloadPrChecks(httpClient, f.repo, pr)
return preloadPrChecks(httpClient, f.baseRefRepo, pr)
})
}
if getProjectItems {
g.Go(func() error {
apiClient := api.NewClientFromHTTP(httpClient)
err := api.ProjectsV2ItemsForPullRequest(apiClient, f.repo, pr)
err := api.ProjectsV2ItemsForPullRequest(apiClient, f.baseRefRepo, pr)
if err != nil && !api.ProjectsV2IgnorableError(err) {
return err
}
@ -201,7 +288,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
})
}
return pr, f.repo, g.Wait()
return pr, f.baseRefRepo, g.Wait()
}
var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)`)
@ -230,59 +317,70 @@ func (f *finder) parseURL(prURL string) (ghrepo.Interface, int, error) {
return repo, prNumber, nil
}
var prHeadRE = regexp.MustCompile(`^refs/pull/(\d+)/head$`)
func (f *finder) parseCurrentBranch() (string, int, error) {
prHeadRef, err := f.branchFn()
if err != nil {
return "", 0, err
func ParsePRRefs(currentBranchName string, branchConfig git.BranchConfig, parsedPushRevision string, pushDefault string, remotePushDefault string, baseRefRepo ghrepo.Interface, rems remotes.Remotes) (PullRequestRefs, error) {
prRefs := PullRequestRefs{
BaseRepo: baseRefRepo,
}
branchConfig, err := f.branchConfig(prHeadRef)
if err != nil {
return "", 0, err
// If @{push} resolves, then we have all the information we need to determine the head repo
// and branch name. It is of the form <remote>/<branch>.
if parsedPushRevision != "" {
for _, r := range rems {
// Find the remote who's name matches the push <remote> prefix
if strings.HasPrefix(parsedPushRevision, r.Name+"/") {
prRefs.BranchName = strings.TrimPrefix(parsedPushRevision, r.Name+"/")
prRefs.HeadRepo = r.Repo
return prRefs, nil
}
}
remoteNames := make([]string, len(rems))
for i, r := range rems {
remoteNames[i] = r.Name
}
return PullRequestRefs{}, fmt.Errorf("no remote for %q found in %q", parsedPushRevision, strings.Join(remoteNames, ", "))
}
// the branch is configured to merge a special PR head ref
if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil {
prNumber, _ := strconv.Atoi(m[1])
return "", prNumber, nil
// We assume the PR's branch name is the same as whatever f.BranchFn() returned earlier
// unless the user has specified push.default = upstream or tracking, then we use the
// branch name from the merge ref.
prRefs.BranchName = currentBranchName
if pushDefault == "upstream" || pushDefault == "tracking" {
prRefs.BranchName = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/")
}
var gitRemoteRepo ghrepo.Interface
if branchConfig.RemoteURL != nil {
// the branch merges from a remote specified by URL
if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil {
gitRemoteRepo = r
// To get the HeadRepo, we look to the git config. The HeadRepo comes from one of the following, in order of precedence:
// 1. branch.<name>.pushRemote
// 2. remote.pushDefault
// 3. branch.<name>.remote
if branchConfig.PushRemoteName != "" {
if r, err := rems.FindByName(branchConfig.PushRemoteName); err == nil {
prRefs.HeadRepo = r.Repo
}
} else if branchConfig.PushRemoteURL != nil {
if r, err := ghrepo.FromURL(branchConfig.PushRemoteURL); err == nil {
prRefs.HeadRepo = r
}
} else if remotePushDefault != "" {
if r, err := rems.FindByName(remotePushDefault); err == nil {
prRefs.HeadRepo = r.Repo
}
} else if branchConfig.RemoteName != "" {
// the branch merges from a remote specified by name
rem, _ := f.remotesFn()
if r, err := rem.FindByName(branchConfig.RemoteName); err == nil {
gitRemoteRepo = r
if r, err := rems.FindByName(branchConfig.RemoteName); err == nil {
prRefs.HeadRepo = r.Repo
}
} else if branchConfig.RemoteURL != nil {
if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil {
prRefs.HeadRepo = r
}
}
if gitRemoteRepo != nil {
if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") {
prHeadRef = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/")
}
// prepend `OWNER:` if this branch is pushed to a fork
// This is determined by:
// - The repo having a different owner
// - The repo having the same owner but a different name (private org fork)
// I suspect that the implementation of the second case may be broken in the face
// of a repo rename, where the remote hasn't been updated locally. This is a
// frequent issue in commands that use SmartBaseRepoFunc. It's not any worse than not
// supporting this case at all though.
sameOwner := strings.EqualFold(gitRemoteRepo.RepoOwner(), f.repo.RepoOwner())
sameOwnerDifferentRepoName := sameOwner && !strings.EqualFold(gitRemoteRepo.RepoName(), f.repo.RepoName())
if !sameOwner || sameOwnerDifferentRepoName {
prHeadRef = fmt.Sprintf("%s:%s", gitRemoteRepo.RepoOwner(), prHeadRef)
}
// The PR merges from a branch in the same repo as the base branch (usually the default branch)
if prRefs.HeadRepo == nil {
prRefs.HeadRepo = baseRefRepo
}
return prHeadRef, 0, nil
return prRefs, nil
}
func findByNumber(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.PullRequest, error) {
@ -315,7 +413,7 @@ func findByNumber(httpClient *http.Client, repo ghrepo.Interface, number int, fi
return &resp.Repository.PullRequest, nil
}
func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, headBranch string, stateFilters, fields []string) (*api.PullRequest, error) {
func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, headBranchWithOwnerIfFork string, stateFilters, fields []string) (*api.PullRequest, error) {
type response struct {
Repository struct {
PullRequests struct {
@ -342,9 +440,9 @@ func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, h
}
}`, api.PullRequestGraphQL(fieldSet.ToSlice()))
branchWithoutOwner := headBranch
if idx := strings.Index(headBranch, ":"); idx >= 0 {
branchWithoutOwner = headBranch[idx+1:]
branchWithoutOwner := headBranchWithOwnerIfFork
if idx := strings.Index(headBranchWithOwnerIfFork, ":"); idx >= 0 {
branchWithoutOwner = headBranchWithOwnerIfFork[idx+1:]
}
variables := map[string]interface{}{
@ -367,18 +465,17 @@ func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, h
})
for _, pr := range prs {
headBranchMatches := pr.HeadLabel() == headBranch
headBranchMatches := pr.HeadLabel() == headBranchWithOwnerIfFork
baseBranchEmptyOrMatches := baseBranch == "" || pr.BaseRefName == baseBranch
// When the head is the default branch, it doesn't really make sense to show merged or closed PRs.
// https://github.com/cli/cli/issues/4263
isNotClosedOrMergedWhenHeadIsDefault := pr.State == "OPEN" || resp.Repository.DefaultBranchRef.Name != headBranch
isNotClosedOrMergedWhenHeadIsDefault := pr.State == "OPEN" || resp.Repository.DefaultBranchRef.Name != headBranchWithOwnerIfFork
if headBranchMatches && baseBranchEmptyOrMatches && isNotClosedOrMergedWhenHeadIsDefault {
return &pr, nil
}
}
return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", headBranch)}
return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", headBranchWithOwnerIfFork)}
}
func preloadPrReviews(httpClient *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error {

View file

@ -2,6 +2,7 @@ package shared
import (
"errors"
"fmt"
"net/http"
"net/url"
"testing"
@ -15,16 +16,50 @@ import (
)
type args struct {
baseRepoFn func() (ghrepo.Interface, error)
branchFn func() (string, error)
branchConfig func(string) (git.BranchConfig, error)
remotesFn func() (context.Remotes, error)
selector string
fields []string
baseBranch string
baseRepoFn func() (ghrepo.Interface, error)
branchFn func() (string, error)
branchConfig func(string) (git.BranchConfig, error)
pushDefault func() (string, error)
remotePushDefault func() (string, error)
parsePushRevision func(string) (string, error)
selector string
fields []string
baseBranch string
}
func TestFind(t *testing.T) {
// TODO: Abstract these out meaningfully for reuse in parsePRRefs tests
originOwnerUrl, err := url.Parse("https://github.com/ORIGINOWNER/REPO.git")
if err != nil {
t.Fatal(err)
}
remoteOrigin := context.Remote{
Remote: &git.Remote{
Name: "origin",
FetchURL: originOwnerUrl,
},
Repo: ghrepo.New("ORIGINOWNER", "REPO"),
}
remoteOther := context.Remote{
Remote: &git.Remote{
Name: "other",
FetchURL: originOwnerUrl,
},
Repo: ghrepo.New("ORIGINOWNER", "OTHER-REPO"),
}
upstreamOwnerUrl, err := url.Parse("https://github.com/UPSTREAMOWNER/REPO.git")
if err != nil {
t.Fatal(err)
}
remoteUpstream := context.Remote{
Remote: &git.Remote{
Name: "upstream",
FetchURL: upstreamOwnerUrl,
},
Repo: ghrepo.New("UPSTREAMOWNER", "REPO"),
}
tests := []struct {
name string
args args
@ -36,11 +71,13 @@ func TestFind(t *testing.T) {
{
name: "number argument",
args: args{
selector: "13",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
selector: "13",
fields: []string{"id", "number"},
baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil),
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -50,7 +87,7 @@ func TestFind(t *testing.T) {
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
wantRepo: "https://github.com/ORIGINOWNER/REPO",
},
{
name: "number argument with base branch",
@ -58,9 +95,16 @@ func TestFind(t *testing.T) {
selector: "13",
baseBranch: "main",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil),
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{
PushRemoteName: remoteOrigin.Remote.Name,
}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
parsePushRevision: stubParsedPushRevision("", nil),
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -73,22 +117,26 @@ func TestFind(t *testing.T) {
"baseRefName": "main",
"headRefName": "13",
"isCrossRepository": false,
"headRepositoryOwner": {"login":"OWNER"}
"headRepositoryOwner": {"login":"ORIGINOWNER"}
}
]}
}}}`))
},
wantPR: 123,
wantRepo: "https://github.com/OWNER/REPO",
wantRepo: "https://github.com/ORIGINOWNER/REPO",
},
{
name: "baseRepo is error",
args: args{
selector: "13",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return nil, errors.New("baseRepoErr")
selector: "13",
fields: []string{"id", "number"},
baseRepoFn: stubBaseRepoFn(nil, errors.New("baseRepoErr")),
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
},
wantErr: true,
},
@ -103,24 +151,32 @@ func TestFind(t *testing.T) {
{
name: "number only",
args: args{
selector: "13",
fields: []string{"number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
selector: "13",
fields: []string{"number"},
baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil),
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
},
httpStub: nil,
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
wantRepo: "https://github.com/ORIGINOWNER/REPO",
},
{
name: "number with hash argument",
args: args{
selector: "#13",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
selector: "#13",
fields: []string{"id", "number"},
baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil),
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -130,14 +186,20 @@ func TestFind(t *testing.T) {
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
wantRepo: "https://github.com/ORIGINOWNER/REPO",
},
{
name: "URL argument",
name: "PR URL argument",
args: args{
selector: "https://example.org/OWNER/REPO/pull/13/files",
fields: []string{"id", "number"},
baseRepoFn: nil,
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -150,13 +212,50 @@ func TestFind(t *testing.T) {
wantRepo: "https://example.org/OWNER/REPO",
},
{
name: "branch argument",
name: "PR URL argument and not in a local git repo",
args: args{
selector: "blueberries",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
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{
selector: "blueberries",
fields: []string{"id", "number"},
baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil),
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
parsePushRevision: stubParsedPushRevision("", nil),
remotePushDefault: stubRemotePushDefault("", nil),
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -169,7 +268,7 @@ func TestFind(t *testing.T) {
"baseRefName": "main",
"headRefName": "blueberries",
"isCrossRepository": false,
"headRepositoryOwner": {"login":"OWNER"}
"headRepositoryOwner": {"login":"ORIGINOWNER"}
},
{
"number": 13,
@ -177,13 +276,13 @@ func TestFind(t *testing.T) {
"baseRefName": "main",
"headRefName": "blueberries",
"isCrossRepository": false,
"headRepositoryOwner": {"login":"OWNER"}
"headRepositoryOwner": {"login":"ORIGINOWNER"}
}
]}
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
wantRepo: "https://github.com/ORIGINOWNER/REPO",
},
{
name: "branch argument with base branch",
@ -194,6 +293,13 @@ func TestFind(t *testing.T) {
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
parsePushRevision: stubParsedPushRevision("", nil),
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -233,7 +339,10 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
parsePushRevision: stubParsedPushRevision("", nil),
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -265,7 +374,10 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
parsePushRevision: stubParsedPushRevision("", nil),
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -303,26 +415,21 @@ func TestFind(t *testing.T) {
wantErr: true,
},
{
name: "current branch with upstream configuration",
name: "when the current branch is configured to push to and pull from 'upstream' and push.default = upstream but the repo push/pulls from 'origin', it finds the PR associated with the upstream repo and returns origin as the base repo",
args: args{
selector: "",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
selector: "",
fields: []string{"id", "number"},
baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil),
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{
MergeRef: "refs/heads/blue-upstream-berries",
RemoteName: "origin",
MergeRef: "refs/heads/blue-upstream-berries",
PushRemoteName: "upstream",
}, nil),
remotesFn: func() (context.Remotes, error) {
return context.Remotes{{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("UPSTREAMOWNER", "REPO"),
}}, nil
},
pushDefault: stubPushDefault("upstream", nil),
remotePushDefault: stubRemotePushDefault("", nil),
parsePushRevision: stubParsedPushRevision("", nil),
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -341,27 +448,28 @@ func TestFind(t *testing.T) {
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
wantRepo: "https://github.com/ORIGINOWNER/REPO",
},
{
name: "current branch with upstream RemoteURL configuration",
// The current BRANCH is configured to push to and pull from a URL (upstream, in this example)
// which is different from what the REPO is configured to push to and pull from (origin, in this example)
// and push.default = upstream. It should find the PR associated with the upstream repo and return
// origin as the base repo
name: "when push.default = upstream and the current branch is configured to push/pull from a different remote than the repo",
args: args{
selector: "",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
selector: "",
fields: []string{"id", "number"},
baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil),
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: func(branch string) (git.BranchConfig, error) {
u, _ := url.Parse("https://github.com/UPSTREAMOWNER/REPO")
return stubBranchConfig(git.BranchConfig{
MergeRef: "refs/heads/blue-upstream-berries",
RemoteURL: u,
}, nil)(branch)
},
remotesFn: nil,
branchConfig: stubBranchConfig(git.BranchConfig{
MergeRef: "refs/heads/blue-upstream-berries",
PushRemoteURL: remoteUpstream.Remote.FetchURL,
}, nil),
pushDefault: stubPushDefault("upstream", nil),
remotePushDefault: stubRemotePushDefault("", nil),
parsePushRevision: stubParsedPushRevision("", nil),
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -380,28 +488,21 @@ func TestFind(t *testing.T) {
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
wantRepo: "https://github.com/ORIGINOWNER/REPO",
},
{
name: "current branch with upstream and fork in same org",
args: args{
selector: "",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
selector: "",
fields: []string{"id", "number"},
baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil),
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{
RemoteName: "origin",
}, nil),
remotesFn: func() (context.Remotes, error) {
return context.Remotes{{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("OWNER", "REPO-FORK"),
}}, nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
parsePushRevision: stubParsedPushRevision("other/blueberries", nil),
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -414,13 +515,13 @@ func TestFind(t *testing.T) {
"baseRefName": "main",
"headRefName": "blueberries",
"isCrossRepository": true,
"headRepositoryOwner": {"login":"OWNER"}
"headRepositoryOwner": {"login":"ORIGINOWNER"}
}
]}
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
wantRepo: "https://github.com/ORIGINOWNER/REPO",
},
{
name: "current branch made by pr checkout",
@ -461,6 +562,8 @@ func TestFind(t *testing.T) {
branchConfig: stubBranchConfig(git.BranchConfig{
MergeRef: "refs/pull/13/head",
}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -521,10 +624,17 @@ func TestFind(t *testing.T) {
httpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
baseRepoFn: tt.args.baseRepoFn,
branchFn: tt.args.branchFn,
branchConfig: tt.args.branchConfig,
remotesFn: tt.args.remotesFn,
baseRepoFn: tt.args.baseRepoFn,
branchFn: tt.args.branchFn,
branchConfig: tt.args.branchConfig,
pushDefault: tt.args.pushDefault,
remotePushDefault: tt.args.remotePushDefault,
parsePushRevision: tt.args.parsePushRevision,
remotesFn: stubRemotes(context.Remotes{
&remoteOrigin,
&remoteOther,
&remoteUpstream,
}, nil),
}
pr, repo, err := f.Find(FindOptions{
@ -557,42 +667,307 @@ func TestFind(t *testing.T) {
}
}
func Test_parseCurrentBranch(t *testing.T) {
func TestParsePRRefs(t *testing.T) {
originOwnerUrl, err := url.Parse("https://github.com/ORIGINOWNER/REPO.git")
if err != nil {
t.Fatal(err)
}
remoteOrigin := context.Remote{
Remote: &git.Remote{
Name: "origin",
FetchURL: originOwnerUrl,
},
Repo: ghrepo.New("ORIGINOWNER", "REPO"),
}
remoteOther := context.Remote{
Remote: &git.Remote{
Name: "other",
FetchURL: originOwnerUrl,
},
Repo: ghrepo.New("ORIGINOWNER", "REPO"),
}
upstreamOwnerUrl, err := url.Parse("https://github.com/UPSTREAMOWNER/REPO.git")
if err != nil {
t.Fatal(err)
}
remoteUpstream := context.Remote{
Remote: &git.Remote{
Name: "upstream",
FetchURL: upstreamOwnerUrl,
},
Repo: ghrepo.New("UPSTREAMOWNER", "REPO"),
}
tests := []struct {
name string
args args
wantSelector string
wantPR int
wantError error
name string
branchConfig git.BranchConfig
pushDefault string
parsedPushRevision string
remotePushDefault string
currentBranchName string
baseRefRepo ghrepo.Interface
rems context.Remotes
wantPRRefs PullRequestRefs
wantErr error
}{
{
name: "failed branch config",
args: args{
branchConfig: stubBranchConfig(git.BranchConfig{}, errors.New("branchConfigErr")),
branchFn: func() (string, error) {
return "blueberries", nil
},
name: "When the branch is called 'blueberries' with an empty branch config, it returns the correct PullRequestRefs",
branchConfig: git.BranchConfig{},
currentBranchName: "blueberries",
baseRefRepo: remoteOrigin.Repo,
wantPRRefs: PullRequestRefs{
BranchName: "blueberries",
HeadRepo: remoteOrigin.Repo,
BaseRepo: remoteOrigin.Repo,
},
wantSelector: "",
wantPR: 0,
wantError: errors.New("branchConfigErr"),
wantErr: nil,
},
{
name: "When the branch is called 'otherBranch' with an empty branch config, it returns the correct PullRequestRefs",
branchConfig: git.BranchConfig{},
currentBranchName: "otherBranch",
baseRefRepo: remoteOrigin.Repo,
wantPRRefs: PullRequestRefs{
BranchName: "otherBranch",
HeadRepo: remoteOrigin.Repo,
BaseRepo: remoteOrigin.Repo,
},
wantErr: nil,
},
{
name: "When the branch name doesn't match the branch name in BranchConfig.Push, it returns the BranchConfig.Push branch name",
parsedPushRevision: "origin/pushBranch",
currentBranchName: "blueberries",
baseRefRepo: remoteOrigin.Repo,
rems: context.Remotes{
&remoteOrigin,
},
wantPRRefs: PullRequestRefs{
BranchName: "pushBranch",
HeadRepo: remoteOrigin.Repo,
BaseRepo: remoteOrigin.Repo,
},
wantErr: nil,
},
{
name: "When the push revision doesn't match a remote, it returns an error",
parsedPushRevision: "origin/differentPushBranch",
currentBranchName: "blueberries",
baseRefRepo: remoteOrigin.Repo,
rems: context.Remotes{
&remoteUpstream,
&remoteOther,
},
wantPRRefs: PullRequestRefs{},
wantErr: fmt.Errorf("no remote for %q found in %q", "origin/differentPushBranch", "upstream, other"),
},
{
name: "When the branch name doesn't match a different branch name in BranchConfig.Push and the remote isn't 'origin', it returns the BranchConfig.Push branch name",
parsedPushRevision: "other/pushBranch",
currentBranchName: "blueberries",
baseRefRepo: remoteOrigin.Repo,
rems: context.Remotes{
&remoteOther,
},
wantPRRefs: PullRequestRefs{
BranchName: "pushBranch",
HeadRepo: remoteOther.Repo,
BaseRepo: remoteOrigin.Repo,
},
wantErr: nil,
},
{
name: "When the push remote is the same as the baseRepo, it returns the baseRepo as the PullRequestRefs HeadRepo",
branchConfig: git.BranchConfig{
PushRemoteName: remoteOrigin.Remote.Name,
},
currentBranchName: "blueberries",
baseRefRepo: remoteOrigin.Repo,
rems: context.Remotes{
&remoteOrigin,
&remoteUpstream,
},
wantPRRefs: PullRequestRefs{
BranchName: "blueberries",
HeadRepo: remoteOrigin.Repo,
BaseRepo: remoteOrigin.Repo,
},
wantErr: nil,
},
{
name: "When the push remote is different from the baseRepo, it returns the push remote repo as the PullRequestRefs HeadRepo",
branchConfig: git.BranchConfig{
PushRemoteName: remoteOrigin.Remote.Name,
},
currentBranchName: "blueberries",
baseRefRepo: remoteUpstream.Repo,
rems: context.Remotes{
&remoteOrigin,
&remoteUpstream,
},
wantPRRefs: PullRequestRefs{
BranchName: "blueberries",
HeadRepo: remoteOrigin.Repo,
BaseRepo: remoteUpstream.Repo,
},
wantErr: nil,
},
{
name: "When the push remote defined by a URL and the baseRepo is different from the push remote, it returns the push remote repo as the PullRequestRefs HeadRepo",
branchConfig: git.BranchConfig{
PushRemoteURL: remoteOrigin.Remote.FetchURL,
},
currentBranchName: "blueberries",
baseRefRepo: remoteUpstream.Repo,
rems: context.Remotes{
&remoteOrigin,
&remoteUpstream,
},
wantPRRefs: PullRequestRefs{
BranchName: "blueberries",
HeadRepo: remoteOrigin.Repo,
BaseRepo: remoteUpstream.Repo,
},
wantErr: nil,
},
{
name: "When the push remote and merge ref are configured to a different repo and push.default = upstream, it should return the branch name from the other repo",
branchConfig: git.BranchConfig{
PushRemoteName: remoteUpstream.Remote.Name,
MergeRef: "refs/heads/blue-upstream-berries",
},
pushDefault: "upstream",
currentBranchName: "blueberries",
baseRefRepo: remoteOrigin.Repo,
rems: context.Remotes{
&remoteOrigin,
&remoteUpstream,
},
wantPRRefs: PullRequestRefs{
BranchName: "blue-upstream-berries",
HeadRepo: remoteUpstream.Repo,
BaseRepo: remoteOrigin.Repo,
},
wantErr: nil,
},
{
name: "When the push remote and merge ref are configured to a different repo and push.default = tracking, it should return the branch name from the other repo",
branchConfig: git.BranchConfig{
PushRemoteName: remoteUpstream.Remote.Name,
MergeRef: "refs/heads/blue-upstream-berries",
},
pushDefault: "tracking",
currentBranchName: "blueberries",
baseRefRepo: remoteOrigin.Repo,
rems: context.Remotes{
&remoteOrigin,
&remoteUpstream,
},
wantPRRefs: PullRequestRefs{
BranchName: "blue-upstream-berries",
HeadRepo: remoteUpstream.Repo,
BaseRepo: remoteOrigin.Repo,
},
wantErr: nil,
},
{
name: "When remote.pushDefault is set, it returns the correct PullRequestRefs",
branchConfig: git.BranchConfig{},
remotePushDefault: remoteUpstream.Remote.Name,
currentBranchName: "blueberries",
baseRefRepo: remoteOrigin.Repo,
rems: context.Remotes{
&remoteOrigin,
&remoteUpstream,
},
wantPRRefs: PullRequestRefs{
BranchName: "blueberries",
HeadRepo: remoteUpstream.Repo,
BaseRepo: remoteOrigin.Repo,
},
wantErr: nil,
},
{
name: "When the remote name is set on the branch, it returns the correct PullRequestRefs",
branchConfig: git.BranchConfig{
RemoteName: remoteUpstream.Remote.Name,
},
currentBranchName: "blueberries",
baseRefRepo: remoteOrigin.Repo,
rems: context.Remotes{
&remoteOrigin,
&remoteUpstream,
},
wantPRRefs: PullRequestRefs{
BranchName: "blueberries",
HeadRepo: remoteUpstream.Repo,
BaseRepo: remoteOrigin.Repo,
},
wantErr: nil,
},
{
name: "When the remote URL is set on the branch, it returns the correct PullRequestRefs",
branchConfig: git.BranchConfig{
RemoteURL: remoteUpstream.Remote.FetchURL,
},
currentBranchName: "blueberries",
baseRefRepo: remoteOrigin.Repo,
rems: context.Remotes{
&remoteOrigin,
&remoteUpstream,
},
wantPRRefs: PullRequestRefs{
BranchName: "blueberries",
HeadRepo: remoteUpstream.Repo,
BaseRepo: remoteOrigin.Repo,
},
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := finder{
httpClient: func() (*http.Client, error) {
return &http.Client{}, nil
},
baseRepoFn: tt.args.baseRepoFn,
branchFn: tt.args.branchFn,
branchConfig: tt.args.branchConfig,
remotesFn: tt.args.remotesFn,
prRefs, err := ParsePRRefs(tt.currentBranchName, tt.branchConfig, tt.parsedPushRevision, tt.pushDefault, tt.remotePushDefault, tt.baseRefRepo, tt.rems)
if tt.wantErr != nil {
require.Equal(t, tt.wantErr, err)
} else {
require.NoError(t, err)
}
selector, pr, err := f.parseCurrentBranch()
assert.Equal(t, tt.wantSelector, selector)
assert.Equal(t, tt.wantPR, pr)
assert.Equal(t, tt.wantError, err)
require.Equal(t, tt.wantPRRefs, prRefs)
})
}
}
func TestPRRefs_GetPRHeadLabel(t *testing.T) {
originRepo := ghrepo.New("ORIGINOWNER", "REPO")
upstreamRepo := ghrepo.New("UPSTREAMOWNER", "REPO")
tests := []struct {
name string
prRefs PullRequestRefs
want string
}{
{
name: "When the HeadRepo and BaseRepo match, it returns the branch name",
prRefs: PullRequestRefs{
BranchName: "blueberries",
HeadRepo: originRepo,
BaseRepo: originRepo,
},
want: "blueberries",
},
{
name: "When the HeadRepo and BaseRepo do not match, it returns the prepended HeadRepo owner to the branch name",
prRefs: PullRequestRefs{
BranchName: "blueberries",
HeadRepo: originRepo,
BaseRepo: upstreamRepo,
},
want: "ORIGINOWNER:blueberries",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, tt.prRefs.GetPRHeadLabel())
})
}
}
@ -602,3 +977,33 @@ func stubBranchConfig(branchConfig git.BranchConfig, err error) func(string) (gi
return branchConfig, err
}
}
func stubRemotes(remotes context.Remotes, err error) func() (context.Remotes, error) {
return func() (context.Remotes, error) {
return remotes, err
}
}
func stubBaseRepoFn(baseRepo ghrepo.Interface, err error) func() (ghrepo.Interface, error) {
return func() (ghrepo.Interface, error) {
return baseRepo, err
}
}
func stubPushDefault(pushDefault string, err error) func() (string, error) {
return func() (string, error) {
return pushDefault, err
}
}
func stubRemotePushDefault(remotePushDefault string, err error) func() (string, error) {
return func() (string, error) {
return remotePushDefault, err
}
}
func stubParsedPushRevision(parsedPushRevision string, err error) func(string) (string, error) {
return func(_ string) (string, error) {
return parsedPushRevision, err
}
}

196
pkg/cmd/pr/shared/lister.go Normal file
View 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
}

View file

@ -7,7 +7,6 @@ import (
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/cli/cli/v2/api"
@ -78,27 +77,57 @@ func statusRun(opts *StatusOptions) error {
return err
}
baseRepo, err := opts.BaseRepo()
baseRefRepo, err := opts.BaseRepo()
if err != nil {
return err
}
var currentBranch string
var currentBranchName string
var currentPRNumber int
var currentPRHeadRef string
var currentHeadRefBranchName string
if !opts.HasRepoOverride {
currentBranch, err = opts.Branch()
// 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)
}
remotes, _ := opts.Remotes()
branchConfig, err := opts.GitClient.ReadBranchConfig(ctx, currentBranch)
branchConfig, err := opts.GitClient.ReadBranchConfig(ctx, currentBranchName)
if err != nil {
return err
}
currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(branchConfig, baseRepo, currentBranch, remotes)
// 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 {
currentPRNumber, _ = strconv.Atoi(m[1])
}
if currentPRNumber == 0 {
remotes, err := opts.Remotes()
if err != nil {
return err
}
// Suppressing these errors as we have other means of computing the PullRequestRefs when these fail.
parsedPushRevision, _ := opts.GitClient.ParsePushRevision(ctx, currentBranchName)
remotePushDefault, err := opts.GitClient.RemotePushDefault(ctx)
if err != nil {
return err
}
pushDefault, err := opts.GitClient.PushDefault(ctx)
if err != nil {
return err
}
prRefs, err := shared.ParsePRRefs(currentBranchName, branchConfig, parsedPushRevision, pushDefault, remotePushDefault, baseRefRepo, remotes)
if err != nil {
return err
}
currentHeadRefBranchName = prRefs.BranchName
}
if err != nil {
return fmt.Errorf("could not query for pull request for current branch: %w", err)
}
@ -107,7 +136,7 @@ func statusRun(opts *StatusOptions) error {
options := requestOptions{
Username: "@me",
CurrentPR: currentPRNumber,
HeadRef: currentPRHeadRef,
HeadRef: currentHeadRefBranchName,
ConflictStatus: opts.ConflictStatus,
}
if opts.Exporter != nil {
@ -116,7 +145,7 @@ func statusRun(opts *StatusOptions) error {
if opts.Detector == nil {
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost())
opts.Detector = fd.NewDetector(cachedClient, baseRefRepo.RepoHost())
}
prFeatures, err := opts.Detector.PullRequestFeatures()
if err != nil {
@ -124,7 +153,7 @@ func statusRun(opts *StatusOptions) error {
}
options.CheckRunAndStatusContextCountsSupported = prFeatures.CheckRunAndStatusContextCounts
prPayload, err := pullRequestStatus(httpClient, baseRepo, options)
prPayload, err := pullRequestStatus(httpClient, baseRefRepo, options)
if err != nil {
return err
}
@ -151,21 +180,21 @@ func statusRun(opts *StatusOptions) error {
cs := opts.IO.ColorScheme()
fmt.Fprintln(out, "")
fmt.Fprintf(out, "Relevant pull requests in %s\n", ghrepo.FullName(baseRepo))
fmt.Fprintf(out, "Relevant pull requests in %s\n", ghrepo.FullName(baseRefRepo))
fmt.Fprintln(out, "")
if !opts.HasRepoOverride {
shared.PrintHeader(opts.IO, "Current branch")
currentPR := prPayload.CurrentPR
if currentPR != nil && currentPR.State != "OPEN" && prPayload.DefaultBranch == currentBranch {
if currentPR != nil && currentPR.State != "OPEN" && prPayload.DefaultBranch == currentBranchName {
currentPR = nil
}
if currentPR != nil {
printPrs(opts.IO, 1, *currentPR)
} else if currentPRHeadRef == "" {
} else if currentHeadRefBranchName == "" {
shared.PrintMessage(opts.IO, " There is no current branch")
} else {
shared.PrintMessage(opts.IO, fmt.Sprintf(" There is no pull request associated with %s", cs.Cyan("["+currentPRHeadRef+"]")))
shared.PrintMessage(opts.IO, fmt.Sprintf(" There is no pull request associated with %s", cs.Cyan("["+currentHeadRefBranchName+"]")))
}
fmt.Fprintln(out)
}
@ -189,55 +218,6 @@ func statusRun(opts *StatusOptions) error {
return nil
}
func prSelectorForCurrentBranch(branchConfig git.BranchConfig, baseRepo ghrepo.Interface, prHeadRef string, rem ghContext.Remotes) (int, string, error) {
// the branch is configured to merge a special PR head ref
prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`)
if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil {
prNumber, err := strconv.Atoi(m[1])
if err != nil {
return 0, "", err
}
return prNumber, prHeadRef, nil
}
var branchOwner string
if branchConfig.RemoteURL != nil {
// the branch merges from a remote specified by URL
r, err := ghrepo.FromURL(branchConfig.RemoteURL)
if err != nil {
// TODO: We aren't returning the error because we discovered that it was shadowed
// before refactoring to its current return pattern. Thus, we aren't confident
// that returning the error won't break existing behavior.
return 0, prHeadRef, nil
}
branchOwner = r.RepoOwner()
} else if branchConfig.RemoteName != "" {
// the branch merges from a remote specified by name
r, err := rem.FindByName(branchConfig.RemoteName)
if err != nil {
// TODO: We aren't returning the error because we discovered that it was shadowed
// before refactoring to its current return pattern. Thus, we aren't confident
// that returning the error won't break existing behavior.
return 0, prHeadRef, nil
}
branchOwner = r.RepoOwner()
}
if branchOwner != "" {
selector := prHeadRef
if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") {
selector = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/")
}
// prepend `OWNER:` if this branch is pushed to a fork
if !strings.EqualFold(branchOwner, baseRepo.RepoOwner()) {
selector = fmt.Sprintf("%s:%s", branchOwner, selector)
}
return 0, selector, nil
}
return 0, prHeadRef, nil
}
func totalApprovals(pr *api.PullRequest) int {
approvals := 0
for _, review := range pr.LatestReviews.Nodes {

View file

@ -4,7 +4,6 @@ import (
"bytes"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"testing"
@ -96,10 +95,13 @@ func TestPRStatus(t *testing.T) {
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.FileResponse("./fixtures/prStatus.json"))
// stub successful git command
// stub successful git commands
rs, cleanup := run.Stub()
defer cleanup(t)
rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
rs.Register(`git config remote.pushDefault`, 0, "")
rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
rs.Register(`git config push.default`, 0, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -130,6 +132,9 @@ func TestPRStatus_reviewsAndChecks(t *testing.T) {
rs, cleanup := run.Stub()
defer cleanup(t)
rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
rs.Register(`git config remote.pushDefault`, 0, "")
rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
rs.Register(`git config push.default`, 0, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -160,6 +165,9 @@ func TestPRStatus_reviewsAndChecksWithStatesByCount(t *testing.T) {
rs, cleanup := run.Stub()
defer cleanup(t)
rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
rs.Register(`git config remote.pushDefault`, 0, "")
rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
rs.Register(`git config push.default`, 0, "")
output, err := runCommandWithDetector(http, "blueberries", true, "", &fd.EnabledDetectorMock{})
if err != nil {
@ -189,6 +197,9 @@ func TestPRStatus_currentBranch_showTheMostRecentPR(t *testing.T) {
rs, cleanup := run.Stub()
defer cleanup(t)
rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
rs.Register(`git config remote.pushDefault`, 0, "")
rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
rs.Register(`git config push.default`, 0, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -222,6 +233,9 @@ func TestPRStatus_currentBranch_defaultBranch(t *testing.T) {
rs, cleanup := run.Stub()
defer cleanup(t)
rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
rs.Register(`git config remote.pushDefault`, 0, "")
rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
rs.Register(`git config push.default`, 0, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -261,6 +275,9 @@ func TestPRStatus_currentBranch_Closed(t *testing.T) {
rs, cleanup := run.Stub()
defer cleanup(t)
rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
rs.Register(`git config remote.pushDefault`, 0, "")
rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
rs.Register(`git config push.default`, 0, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -283,6 +300,9 @@ func TestPRStatus_currentBranch_Closed_defaultBranch(t *testing.T) {
rs, cleanup := run.Stub()
defer cleanup(t)
rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
rs.Register(`git config remote.pushDefault`, 0, "")
rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
rs.Register(`git config push.default`, 0, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -305,6 +325,9 @@ func TestPRStatus_currentBranch_Merged(t *testing.T) {
rs, cleanup := run.Stub()
defer cleanup(t)
rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
rs.Register(`git config remote.pushDefault`, 0, "")
rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
rs.Register(`git config push.default`, 0, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -327,6 +350,9 @@ func TestPRStatus_currentBranch_Merged_defaultBranch(t *testing.T) {
rs, cleanup := run.Stub()
defer cleanup(t)
rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
rs.Register(`git config remote.pushDefault`, 0, "")
rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
rs.Register(`git config push.default`, 0, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -349,6 +375,9 @@ func TestPRStatus_blankSlate(t *testing.T) {
rs, cleanup := run.Stub()
defer cleanup(t)
rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
rs.Register(`git config remote.pushDefault`, 0, "")
rs.Register(`git rev-parse --abbrev-ref blueberries@{push}`, 0, "")
rs.Register(`git config push.default`, 0, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -407,6 +436,9 @@ func TestPRStatus_detachedHead(t *testing.T) {
rs, cleanup := run.Stub()
defer cleanup(t)
rs.Register(`git config --get-regexp \^branch\\.`, 0, "")
rs.Register(`git config remote.pushDefault`, 0, "")
rs.Register(`git rev-parse --abbrev-ref @{push}`, 0, "")
rs.Register(`git config push.default`, 0, "")
output, err := runCommand(http, "", true, "")
if err != nil {
@ -434,216 +466,8 @@ Requesting a code review from you
func TestPRStatus_error_ReadBranchConfig(t *testing.T) {
rs, cleanup := run.Stub()
defer cleanup(t)
rs.Register(`git config --get-regexp \^branch\\.`, 1, "")
// We only need the one stub because this fails early
rs.Register(`git config --get-regexp \^branch\\.`, 2, "")
_, err := runCommand(initFakeHTTP(), "blueberries", true, "")
assert.Error(t, err)
}
func Test_prSelectorForCurrentBranch(t *testing.T) {
tests := []struct {
name string
branchConfig git.BranchConfig
baseRepo ghrepo.Interface
prHeadRef string
remotes context.Remotes
wantPrNumber int
wantSelector string
wantError error
}{
{
name: "Empty branch config",
branchConfig: git.BranchConfig{},
prHeadRef: "monalisa/main",
wantPrNumber: 0,
wantSelector: "monalisa/main",
wantError: nil,
},
{
name: "The branch is configured to merge a special PR head ref",
branchConfig: git.BranchConfig{
MergeRef: "refs/pull/42/head",
},
prHeadRef: "monalisa/main",
wantPrNumber: 42,
wantSelector: "monalisa/main",
wantError: nil,
},
{
name: "Branch merges from a remote specified by URL",
branchConfig: git.BranchConfig{
RemoteURL: &url.URL{
Scheme: "ssh",
User: url.User("git"),
Host: "github.com",
Path: "monalisa/playground.git",
},
},
baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"),
prHeadRef: "monalisa/main",
remotes: context.Remotes{
&context.Remote{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"),
},
},
wantPrNumber: 0,
wantSelector: "monalisa/main",
wantError: nil,
},
{
name: "Branch merges from a remote specified by name",
branchConfig: git.BranchConfig{
RemoteName: "upstream",
},
baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"),
prHeadRef: "monalisa/main",
remotes: context.Remotes{
&context.Remote{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.NewWithHost("forkName", "playground", "github.com"),
},
&context.Remote{
Remote: &git.Remote{Name: "upstream"},
Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"),
},
},
wantPrNumber: 0,
wantSelector: "monalisa/main",
wantError: nil,
},
{
name: "Branch is a fork and merges from a remote specified by URL",
branchConfig: git.BranchConfig{
RemoteURL: &url.URL{
Scheme: "ssh",
User: url.User("git"),
Host: "github.com",
Path: "forkName/playground.git",
},
MergeRef: "refs/heads/main",
},
baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"),
prHeadRef: "monalisa/main",
remotes: context.Remotes{
&context.Remote{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.NewWithHost("forkName", "playground", "github.com"),
},
},
wantPrNumber: 0,
wantSelector: "forkName:main",
wantError: nil,
},
{
name: "Branch is a fork and merges from a remote specified by name",
branchConfig: git.BranchConfig{
RemoteName: "origin",
},
baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"),
prHeadRef: "monalisa/main",
remotes: context.Remotes{
&context.Remote{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.NewWithHost("forkName", "playground", "github.com"),
},
&context.Remote{
Remote: &git.Remote{Name: "upstream"},
Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"),
},
},
wantPrNumber: 0,
wantSelector: "forkName:monalisa/main",
wantError: nil,
},
{
name: "Branch specifies a mergeRef and merges from a remote specified by name",
branchConfig: git.BranchConfig{
RemoteName: "upstream",
MergeRef: "refs/heads/main",
},
baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"),
prHeadRef: "monalisa/main",
remotes: context.Remotes{
&context.Remote{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.NewWithHost("forkName", "playground", "github.com"),
},
&context.Remote{
Remote: &git.Remote{Name: "upstream"},
Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"),
},
},
wantPrNumber: 0,
wantSelector: "main",
wantError: nil,
},
{
name: "Branch is a fork, specifies a mergeRef, and merges from a remote specified by name",
branchConfig: git.BranchConfig{
RemoteName: "origin",
MergeRef: "refs/heads/main",
},
baseRepo: ghrepo.NewWithHost("monalisa", "playground", "github.com"),
prHeadRef: "monalisa/main",
remotes: context.Remotes{
&context.Remote{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.NewWithHost("forkName", "playground", "github.com"),
},
&context.Remote{
Remote: &git.Remote{Name: "upstream"},
Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"),
},
},
wantPrNumber: 0,
wantSelector: "forkName:main",
wantError: nil,
},
{
name: "Remote URL errors",
branchConfig: git.BranchConfig{
RemoteURL: &url.URL{
Scheme: "ssh",
User: url.User("git"),
Host: "github.com",
Path: "/\\invalid?Path/",
},
},
prHeadRef: "monalisa/main",
wantPrNumber: 0,
wantSelector: "monalisa/main",
wantError: nil,
},
{
name: "Remote Name errors",
branchConfig: git.BranchConfig{
RemoteName: "nonexistentRemote",
},
prHeadRef: "monalisa/main",
remotes: context.Remotes{
&context.Remote{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.NewWithHost("forkName", "playground", "github.com"),
},
&context.Remote{
Remote: &git.Remote{Name: "upstream"},
Repo: ghrepo.NewWithHost("monalisa", "playground", "github.com"),
},
},
wantPrNumber: 0,
wantSelector: "monalisa/main",
wantError: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prNum, headRef, err := prSelectorForCurrentBranch(tt.branchConfig, tt.baseRepo, tt.prHeadRef, tt.remotes)
assert.Equal(t, tt.wantPrNumber, prNum)
assert.Equal(t, tt.wantSelector, headRef)
assert.Equal(t, tt.wantError, err)
})
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View 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())
}
})
}
}

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
@ -124,6 +125,10 @@ func renameRun(opts *RenameOptions) error {
}
}
if strings.Contains(newRepoName, "/") {
return fmt.Errorf("New repository name cannot contain '/' character - to transfer a repository to a new owner, you must follow additional steps on GitHub.com. For more information on transferring repository ownership, see <https://docs.github.com/en/repositories/creating-and-managing-repositories/transferring-a-repository>.")
}
if opts.DoConfirm {
var confirmed bool
if confirmed, err = opts.Prompter.Confirm(fmt.Sprintf(

View file

@ -113,6 +113,8 @@ func TestRenameRun(t *testing.T) {
promptStubs func(*prompter.MockPrompter)
wantOut string
tty bool
wantErr bool
errMsg string
}{
{
name: "none argument",
@ -217,6 +219,16 @@ func TestRenameRun(t *testing.T) {
},
wantOut: "",
},
{
name: "error on name with slash",
tty: true,
opts: RenameOptions{
newRepoSelector: "org/new-name",
},
wantErr: true,
errMsg: "New repository name cannot contain '/' character - to transfer a repository to a new owner, you must follow additional steps on GitHub.com. For more information on transferring repository ownership, see <https://docs.github.com/en/repositories/creating-and-managing-repositories/transferring-a-repository>.",
},
}
for _, tt := range testCases {
@ -268,6 +280,10 @@ func TestRenameRun(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
defer reg.Verify(t)
err := renameRun(&tt.opts)
if tt.wantErr {
assert.EqualError(t, err, tt.errMsg)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantOut, stdout.String())
})

View file

@ -119,6 +119,8 @@ func listRun(opts *ListOptions) error {
}
opts.IO.StartProgressIndicator()
defer opts.IO.StopProgressIndicator()
if opts.WorkflowSelector != "" {
// initially the workflow state is limited to 'active'
states := []workflowShared.WorkflowState{workflowShared.Active}