Merge branch 'trunk' into trunk

This commit is contained in:
Barak Amar 2025-04-17 09:56:12 -04:00 committed by GitHub
commit 265139f268
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 6101 additions and 1817 deletions

View file

@ -152,7 +152,7 @@ There are two common ways to verify a downloaded release, depending if `gh` is a
$ cosign verify-blob-attestation --bundle cli-cli-attestation-3120304.sigstore.json \
--new-bundle-format \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
--certificate-identity-regexp="^https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk$" \
--certificate-identity="https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk" \
gh_2.62.0_macOS_arm64.zip
Verified OK
```

View file

@ -12,6 +12,7 @@ defer gh repo delete --yes ${ORG}/${REPO}
# Create a fork
exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${REPO}-fork
sleep 5
# Defer fork cleanup
defer gh repo delete --yes ${ORG}/${REPO}-fork

View file

@ -0,0 +1,46 @@
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
env FORK=${REPO}-fork
# Use gh as a credential helper
exec gh auth setup-git
# Get the current username for the fork owner
exec gh api user --jq .login
stdout2env USER
# 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}
# Create a user fork of repository. This will be owned by USER.
exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
sleep 5
# Defer repo cleanup of fork
defer gh repo delete --yes ${USER}/${FORK}
# Retrieve fork repository information
exec gh repo view ${USER}/${FORK} --json id --jq '.id'
stdout2env FORK_ID
exec gh repo clone ${USER}/${FORK}
cd ${FORK}
# Prepare a branch to commit
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Upstream Commit'
exec git push upstream feature-branch
# Prepare an additional commit
exec git commit --allow-empty -m 'Fork Commit'
exec git push origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
# Check the PR is indeed created
exec gh pr view ${USER}:feature-branch --json headRefName,headRepository,baseRefName,isCrossRepository
stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}

View file

@ -0,0 +1,27 @@
# Use gh as a credential helper
exec gh auth setup-git
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# 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}
# Prepare a branch to PR
cd ${REPO}
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Leave the repo so there's no local repo
cd ${WORK}
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body' --repo ${ORG}/${REPO} --head feature-branch
stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1

View file

@ -0,0 +1,49 @@
skip 'it creates a fork owned by the user running the test'
# 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
# Get the current username for the fork owner
exec gh api user --jq .login
stdout2env USER
# 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}
# Create a user fork of repository. This will be owned by USER.
exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
sleep 5
# Defer repo cleanup of fork
defer gh repo delete --yes ${USER}/${FORK}
# Retrieve fork repository information
exec gh repo view ${USER}/${FORK} --json id --jq '.id'
stdout2env FORK_ID
# Clone the repo
exec gh repo clone ${USER}/${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
exec git branch --set-upstream-to upstream/main
exec git config branch.feature-branch.pushRemote origin
exec git config unset remote.upstream.gh-resolved
exec git commit --allow-empty -m 'Empty Commit'
exec git push
# Create the PR spanning upstream and fork repositories
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
# Assert that the PR was created with the correct head repository and refs
exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository
stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}

View file

@ -0,0 +1,53 @@
skip 'it creates a fork owned by the user running the test'
# 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
# Get the current username for the fork owner
exec gh api user --jq .login
stdout2env USER
# 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}
# Create a user fork of repository. This will be owned by USER.
exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
sleep 5
# Defer repo cleanup of fork
defer gh repo delete --yes ${USER}/${FORK}
# Retrieve fork repository information
exec gh repo view ${USER}/${FORK} --json id --jq '.id'
stdout2env FORK_ID
# Clone the repo
exec gh repo clone ${USER}/${FORK}
cd ${FORK}
# 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
exec git config unset remote.upstream.gh-resolved
exec git commit --allow-empty -m 'Empty Commit'
exec git push
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
# Assert that the PR was created with the correct head repository and refs
exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository
stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}

View file

@ -0,0 +1,49 @@
skip 'it creates a fork owned by the user running the test'
# 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
# Get the current username for the fork owner
exec gh api user --jq .login
stdout2env USER
# 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}
# Create a user fork of repository. This will be owned by USER.
exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
sleep 5
# Defer repo cleanup of fork
defer gh repo delete --yes ${USER}/${FORK}
# Retrieve fork repository information
exec gh repo view ${USER}/${FORK} --json id --jq '.id'
stdout2env FORK_ID
# Clone the repo
exec gh repo clone ${USER}/${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
exec git branch --set-upstream-to upstream/main
exec git config remote.pushDefault origin
exec git config unset remote.upstream.gh-resolved
exec git commit --allow-empty -m 'Empty Commit'
exec git push
# Create the PR spanning upstream and fork repositories
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
# Assert that the PR was created with the correct head repository and refs
exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository
stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}

View file

@ -0,0 +1,34 @@
# 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 of repo
defer gh repo delete --yes ${ORG}/${REPO}
exec gh repo view ${ORG}/${REPO} --json id --jq '.id'
stdout2env REPO_ID
# 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
exec git branch --set-upstream-to origin/main
exec git commit --allow-empty -m 'Empty Commit'
exec git push origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
# Assert that the PR was created with the correct head repository and refs
exec gh pr view --json headRefName,headRepository,baseRefName,isCrossRepository
stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${REPO_ID}","name":"${REPO}"},"isCrossRepository":false}

View file

@ -0,0 +1,47 @@
skip 'it creates a fork owned by the user running the test'
# 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
# Get the current username for the fork owner
exec gh api user --jq .login
stdout2env USER
# 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}
# Create a user fork of repository. This will be owned by USER.
exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
sleep 5
# Defer repo cleanup of fork
defer gh repo delete --yes ${USER}/${FORK}
# Retrieve fork repository information
exec gh repo view ${USER}/${FORK} --json id --jq '.id'
stdout2env FORK_ID
# Clone the fork
exec gh repo clone ${USER}/${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
exec git branch --set-upstream-to upstream/main
exec git commit --allow-empty -m 'Empty Commit'
exec git push origin feature-branch
# Create the PR spanning upstream and fork repositories
exec gh pr create --title 'Feature Title' --body 'Feature Body' --head ${USER}:feature-branch
stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
# Assert that the PR was created with the correct head repository and refs
exec gh pr view ${USER}:feature-branch --json headRefName,headRepository,baseRefName,isCrossRepository
stdout {"baseRefName":"main","headRefName":"feature-branch","headRepository":{"id":"${FORK_ID}","name":"${FORK}"},"isCrossRepository":true}

View file

@ -1,20 +1,22 @@
# This test is the same as pr-create-basic, except that the git push doesn't include the -u argument
# This causes a git config read to fail during gh pr create, but it should not be fatal
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/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
exec gh repo clone ${ORG}/${REPO}
# Prepare a branch to PR
cd $SCRIPT_NAME-$RANDOM_STRING
cd ${REPO}
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push origin feature-branch

View file

@ -0,0 +1,46 @@
skip 'it creates a fork owned by the user running the test'
# 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
# Get the current username for the fork owner
exec gh api user --jq .login
stdout2env USER
# 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}
# Create a user fork of repository. This will be owned by USER.
exec gh repo fork ${ORG}/${REPO} --fork-name ${FORK}
sleep 5
# Defer repo cleanup of fork
defer gh repo delete --yes ${USER}/${FORK}
# Retrieve fork repository information
exec gh repo view ${USER}/${FORK} --json id --jq '.id'
stdout2env FORK_ID
# Clone the repo
exec gh repo clone ${USER}/${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
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Create the PR spanning upstream and fork repositories
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout https://${GH_HOST}/${ORG}/${REPO}/pull/1
# Assert that the PR was created with the correct head repository and refs
exec gh pr status
! stdout 'There is no pull request associated with'

View file

@ -15,10 +15,11 @@ stdout2env REPO_ID
# Create a fork in the same org
exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK}
sleep 5
# Defer repo cleanup of fork
defer gh repo delete --yes ${ORG}/${FORK}
sleep 1
exec gh repo view ${ORG}/${FORK} --json id --jq '.id'
stdout2env FORK_ID

View file

@ -15,10 +15,11 @@ 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}
sleep 5
# 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
@ -27,7 +28,8 @@ 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 checkout -b feature-branch
exec git branch --set-upstream-to upstream/main
exec git config branch.feature-branch.pushRemote origin
exec git commit --allow-empty -m 'Empty Commit'
exec git push

View file

@ -15,10 +15,11 @@ 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}
sleep 5
# 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
@ -27,7 +28,8 @@ 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 checkout -b feature-branch
exec git branch --set-upstream-to upstream/main
exec git config remote.pushDefault origin
exec git commit --allow-empty -m 'Empty Commit'
exec git push

View file

@ -18,7 +18,8 @@ cd ${REPO}
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
exec git checkout -b feature-branch
exec git branch --set-upstream-to origin/main
# Create the PR
exec git commit --allow-empty -m 'Empty Commit'

View file

@ -9,13 +9,11 @@ defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
# Fork and clone the repo
exec gh repo fork $ORG/$SCRIPT_NAME-$RANDOM_STRING --org $ORG --fork-name $SCRIPT_NAME-$RANDOM_STRING-fork --clone
sleep 5
# Defer fork cleanup
defer gh repo delete $ORG/$SCRIPT_NAME-$RANDOM_STRING-fork --yes
# Sleep so that the BE has time to sync
sleep 5
# Check that the repo was forked
exec gh repo view $ORG/$SCRIPT_NAME-$RANDOM_STRING-fork --json='isFork' --jq='.isFork'
stdout 'true'

View file

@ -12,13 +12,11 @@ defer gh repo delete --yes ${ORG}/${REPO}
# Create a fork
exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${REPO}-fork
sleep 5
# Defer fork cleanup
defer gh repo delete --yes ${ORG}/${REPO}-fork
# Sleep to allow the fork to be created before cloning
sleep 2
# Clone and move into the fork repo
exec gh repo clone ${ORG}/${REPO}-fork
cd ${REPO}-fork

View file

@ -381,7 +381,6 @@ func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte,
// 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|pushremote|%s)$", prefix, MergeBaseConfig)}
cmd, err := c.Command(ctx, args...)
@ -441,18 +440,50 @@ func (c *Client) SetBranchConfig(ctx context.Context, branch, name, value string
return err
}
// PushDefault defines the action git push should take if no refspec is given.
// See: https://git-scm.com/docs/git-config#Documentation/git-config.txt-pushdefault
type PushDefault string
const (
PushDefaultNothing PushDefault = "nothing"
PushDefaultCurrent PushDefault = "current"
PushDefaultUpstream PushDefault = "upstream"
PushDefaultTracking PushDefault = "tracking"
PushDefaultSimple PushDefault = "simple"
PushDefaultMatching PushDefault = "matching"
)
func ParsePushDefault(s string) (PushDefault, error) {
validPushDefaults := map[string]struct{}{
string(PushDefaultNothing): {},
string(PushDefaultCurrent): {},
string(PushDefaultUpstream): {},
string(PushDefaultTracking): {},
string(PushDefaultSimple): {},
string(PushDefaultMatching): {},
}
if _, ok := validPushDefaults[s]; ok {
return PushDefault(s), nil
}
return "", fmt.Errorf("unknown push.default value: %s", s)
}
// 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) {
func (c *Client) PushDefault(ctx context.Context) (PushDefault, error) {
pushDefault, err := c.Config(ctx, "push.default")
if err == nil {
return pushDefault, nil
return ParsePushDefault(pushDefault)
}
// If there is an error that the config key is not set, return the default value
// that git uses since 2.0.
var gitError *GitError
if ok := errors.As(err, &gitError); ok && gitError.ExitCode == 1 {
return "simple", nil
return PushDefaultSimple, nil
}
return "", err
}
@ -473,13 +504,48 @@ func (c *Client) RemotePushDefault(ctx context.Context) (string, error) {
return "", err
}
// ParsePushRevision gets the value of the @{push} revision syntax
// RemoteTrackingRef is the structured form of the string "refs/remotes/<remote>/<branch>".
// For example, the @{push} revision syntax could report "refs/remotes/origin/main" which would
// be parsed into RemoteTrackingRef{Remote: "origin", Branch: "main"}.
type RemoteTrackingRef struct {
Remote string
Branch string
}
func (r RemoteTrackingRef) String() string {
return fmt.Sprintf("refs/remotes/%s/%s", r.Remote, r.Branch)
}
// ParseRemoteTrackingRef parses a string of the form "refs/remotes/<remote>/<branch>" into
// a RemoteTrackingBranch struct. If the string does not match this format, an error is returned.
func ParseRemoteTrackingRef(s string) (RemoteTrackingRef, error) {
parts := strings.Split(s, "/")
if len(parts) != 4 || parts[0] != "refs" || parts[1] != "remotes" {
return RemoteTrackingRef{}, fmt.Errorf("remote tracking branch must have format refs/remotes/<remote>/<branch> but was: %s", s)
}
return RemoteTrackingRef{
Remote: parts[2],
Branch: parts[3],
}, nil
}
// PushRevision 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) PushRevision(ctx context.Context, branch string) (RemoteTrackingRef, error) {
revParseOut, err := c.revParse(ctx, "--symbolic-full-name", branch+"@{push}")
if err != nil {
return RemoteTrackingRef{}, err
}
ref, err := ParseRemoteTrackingRef(firstLine(revParseOut))
if err != nil {
return RemoteTrackingRef{}, fmt.Errorf("could not parse push revision: %v", err)
}
return ref, nil
}
func (c *Client) DeleteLocalTag(ctx context.Context, tag string) error {

View file

@ -952,7 +952,7 @@ func TestClientPushDefault(t *testing.T) {
tests := []struct {
name string
commandResult commandResult
wantPushDefault string
wantPushDefault PushDefault
wantError *GitError
}{
{
@ -961,7 +961,7 @@ func TestClientPushDefault(t *testing.T) {
ExitStatus: 1,
Stderr: "error: key does not contain a section: remote.pushDefault",
},
wantPushDefault: "simple",
wantPushDefault: PushDefaultSimple,
wantError: nil,
},
{
@ -970,7 +970,7 @@ func TestClientPushDefault(t *testing.T) {
ExitStatus: 0,
Stdout: "current",
},
wantPushDefault: "current",
wantPushDefault: PushDefaultCurrent,
wantError: nil,
},
{
@ -1077,17 +1077,17 @@ func TestClientParsePushRevision(t *testing.T) {
name string
branch string
commandResult commandResult
wantParsedPushRevision string
wantError *GitError
wantParsedPushRevision RemoteTrackingRef
wantError error
}{
{
name: "@{push} resolves to origin/branchName",
name: "@{push} resolves to refs/remotes/origin/branchName",
branch: "branchName",
commandResult: commandResult{
ExitStatus: 0,
Stdout: "origin/branchName",
Stdout: "refs/remotes/origin/branchName",
},
wantParsedPushRevision: "origin/branchName",
wantParsedPushRevision: RemoteTrackingRef{Remote: "origin", Branch: "branchName"},
},
{
name: "@{push} doesn't resolve",
@ -1095,16 +1095,25 @@ func TestClientParsePushRevision(t *testing.T) {
ExitStatus: 128,
Stderr: "fatal: git error",
},
wantParsedPushRevision: "",
wantParsedPushRevision: RemoteTrackingRef{},
wantError: &GitError{
ExitCode: 128,
Stderr: "fatal: git error",
},
},
{
name: "@{push} resolves to something surprising",
commandResult: commandResult{
ExitStatus: 0,
Stdout: "not/a/valid/remote/ref",
},
wantParsedPushRevision: RemoteTrackingRef{},
wantError: fmt.Errorf("could not parse push revision: remote tracking branch must have format refs/remotes/<remote>/<branch> but was: not/a/valid/remote/ref"),
},
}
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)
cmd := fmt.Sprintf("path/to/git rev-parse --symbolic-full-name %s@{push}", tt.branch)
cmdCtx := createMockedCommandContext(t, mockedCommands{
args(cmd): tt.commandResult,
})
@ -1112,20 +1121,91 @@ func TestClientParsePushRevision(t *testing.T) {
GitPath: "path/to/git",
commandContext: cmdCtx,
}
pushDefault, err := client.ParsePushRevision(context.Background(), tt.branch)
trackingRef, err := client.PushRevision(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)
var wantErrorAsGit *GitError
if errors.As(err, &wantErrorAsGit) {
var gitError *GitError
require.ErrorAs(t, err, &gitError)
assert.Equal(t, wantErrorAsGit.ExitCode, gitError.ExitCode)
assert.Equal(t, wantErrorAsGit.Stderr, gitError.Stderr)
} else {
assert.Equal(t, err, tt.wantError)
}
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantParsedPushRevision, pushDefault)
assert.Equal(t, tt.wantParsedPushRevision, trackingRef)
})
}
}
func TestRemoteTrackingRef(t *testing.T) {
t.Run("parsing", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
remoteTrackingRef string
wantRemoteTrackingRef RemoteTrackingRef
wantError error
}{
{
name: "valid remote tracking ref",
remoteTrackingRef: "refs/remotes/origin/branchName",
wantRemoteTrackingRef: RemoteTrackingRef{
Remote: "origin",
Branch: "branchName",
},
},
{
name: "incorrect parts",
remoteTrackingRef: "refs/remotes/origin",
wantRemoteTrackingRef: RemoteTrackingRef{},
wantError: fmt.Errorf("remote tracking branch must have format refs/remotes/<remote>/<branch> but was: refs/remotes/origin"),
},
{
name: "incorrect prefix type",
remoteTrackingRef: "invalid/remotes/origin/branchName",
wantRemoteTrackingRef: RemoteTrackingRef{},
wantError: fmt.Errorf("remote tracking branch must have format refs/remotes/<remote>/<branch> but was: invalid/remotes/origin/branchName"),
},
{
name: "incorrect ref type",
remoteTrackingRef: "refs/invalid/origin/branchName",
wantRemoteTrackingRef: RemoteTrackingRef{},
wantError: fmt.Errorf("remote tracking branch must have format refs/remotes/<remote>/<branch> but was: refs/invalid/origin/branchName"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
trackingRef, err := ParseRemoteTrackingRef(tt.remoteTrackingRef)
if tt.wantError != nil {
require.Equal(t, tt.wantError, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantRemoteTrackingRef, trackingRef)
})
}
})
t.Run("stringifying", func(t *testing.T) {
t.Parallel()
remoteTrackingRef := RemoteTrackingRef{
Remote: "origin",
Branch: "branchName",
}
require.Equal(t, "refs/remotes/origin/branchName", remoteTrackingRef.String())
})
}
func TestClientDeleteLocalTag(t *testing.T) {
tests := []struct {
name string
@ -1992,6 +2072,41 @@ func TestCredentialPatternFromHost(t *testing.T) {
}
}
func TestPushDefault(t *testing.T) {
t.Run("it parses valid values correctly", func(t *testing.T) {
t.Parallel()
tests := []struct {
value string
expectedPushDefault PushDefault
}{
{"nothing", PushDefaultNothing},
{"current", PushDefaultCurrent},
{"upstream", PushDefaultUpstream},
{"tracking", PushDefaultTracking},
{"simple", PushDefaultSimple},
{"matching", PushDefaultMatching},
}
for _, test := range tests {
t.Run(test.value, func(t *testing.T) {
t.Parallel()
pushDefault, err := ParsePushDefault(test.value)
require.NoError(t, err)
assert.Equal(t, test.expectedPushDefault, pushDefault)
})
}
})
t.Run("it returns an error for invalid values", func(t *testing.T) {
t.Parallel()
_, err := ParsePushDefault("invalid")
require.Error(t, err)
})
}
func createCommandContext(t *testing.T, exitStatus int, stdout, stderr string) (*exec.Cmd, commandCtx) {
cmd := exec.CommandContext(context.Background(), os.Args[0], "-test.run=TestHelperProcess", "--")
cmd.Env = []string{

View file

@ -43,10 +43,21 @@ func (gc *Command) Output() ([]byte, error) {
out, err := run.PrepareCmd(gc.Cmd).Output()
if err != nil {
ge := GitError{err: err}
// In real implementation, this should be an exec.ExitError, as below,
// but the tests use a different type because exec.ExitError are difficult
// to create. We want to get the exit code and stderr, but stderr
// is not a method and so tests can't access it.
// THIS MEANS THAT TESTS WILL NOT CORRECTLY HAVE STDERR SET,
// but at least tests can get the exit code.
var exitErrorWithExitCode errWithExitCode
if errors.As(err, &exitErrorWithExitCode) {
ge.ExitCode = exitErrorWithExitCode.ExitCode()
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
ge.Stderr = string(exitError.Stderr)
ge.ExitCode = exitError.ExitCode()
}
err = &ge
}

79
go.mod
View file

@ -7,9 +7,11 @@ toolchain go1.23.5
require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/MakeNowJust/heredoc v1.0.0
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2
github.com/briandowns/spinner v1.18.1
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3
github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5
github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc
github.com/cli/go-gh/v2 v2.12.0
github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24
@ -22,13 +24,14 @@ require (
github.com/gabriel-vasile/mimetype v1.4.8
github.com/gdamore/tcell/v2 v2.5.4
github.com/golang/snappy v0.0.4
github.com/google/go-cmp v0.6.0
github.com/google/go-cmp v0.7.0
github.com/google/go-containerregistry v0.20.3
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/websocket v1.5.3
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/hinshun/vt10x v0.0.0-20220119200601-820417d04eec
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
@ -40,18 +43,18 @@ require (
github.com/opentracing/opentracing-go v1.2.0
github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d
github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc
github.com/sigstore/protobuf-specs v0.3.3
github.com/sigstore/sigstore-go v0.7.0
github.com/spf13/cobra v1.8.1
github.com/sigstore/protobuf-specs v0.4.1
github.com/sigstore/sigstore-go v0.7.2
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
github.com/stretchr/testify v1.10.0
github.com/zalando/go-keyring v0.2.5
golang.org/x/crypto v0.35.0
golang.org/x/sync v0.12.0
golang.org/x/term v0.30.0
golang.org/x/text v0.23.0
google.golang.org/grpc v1.69.4
google.golang.org/protobuf v1.36.5
golang.org/x/crypto v0.37.0
golang.org/x/sync v0.13.0
golang.org/x/term v0.31.0
golang.org/x/text v0.24.0
google.golang.org/grpc v1.71.0
google.golang.org/protobuf v1.36.6
gopkg.in/h2non/gock.v1 v1.1.2
gopkg.in/yaml.v3 v3.0.1
)
@ -64,12 +67,17 @@ require (
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/alessio/shellescape v1.4.2 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.0 // indirect
github.com/charmbracelet/bubbletea v1.3.4 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/cli/shurcooL-graphql v0.0.4 // indirect
@ -82,30 +90,32 @@ require (
github.com/docker/cli v27.5.0+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.8.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/go-chi/chi v4.1.2+incompatible // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.23.0 // indirect
github.com/go-openapi/errors v0.22.0 // indirect
github.com/go-openapi/errors v0.22.1 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/loads v0.22.0 // indirect
github.com/go-openapi/runtime v0.28.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/strfmt v0.23.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-openapi/validate v0.24.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/certificate-transparency-go v1.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/in-toto/in-toto-golang v0.9.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@ -116,40 +126,42 @@ require (
github.com/klauspost/compress v1.17.11 // indirect
github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rodaine/table v1.0.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sassoftware/relic v7.2.1+incompatible // indirect
github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect
github.com/shibumi/go-pathspec v1.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect
github.com/sigstore/rekor v1.3.8 // indirect
github.com/sigstore/sigstore v1.8.12 // indirect
github.com/sigstore/timestamp-authority v1.2.4 // indirect
github.com/sigstore/rekor v1.3.9 // indirect
github.com/sigstore/sigstore v1.9.1 // indirect
github.com/sigstore/timestamp-authority v1.2.5 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/viper v1.19.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/viper v1.20.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/theupdateframework/go-tuf v0.7.0 // indirect
@ -163,18 +175,17 @@ require (
github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.mongodb.org/mongo-driver v1.14.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.33.0 // indirect
go.opentelemetry.io/otel/trace v1.33.0 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/net v0.36.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/tools v0.29.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
)

355
go.sum
View file

@ -1,18 +1,17 @@
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
cloud.google.com/go/auth v0.13.0 h1:8Fu8TZy167JkW8Tj3q7dIkr2v4cndv41ouecJx0PAHs=
cloud.google.com/go/auth v0.13.0/go.mod h1:COOjD9gwfKNKz+IIduatIhYJQIc0mG3H102r/EMxX6Q=
cloud.google.com/go/auth/oauth2adapt v0.2.6 h1:V6a6XDu2lTwPZWOawrAa9HUK+DB2zfJyTuciBG5hFkU=
cloud.google.com/go/auth/oauth2adapt v0.2.6/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8=
cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg=
cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME=
cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc=
cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=
cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/iam v1.2.2 h1:ozUSofHUGf/F4tCNy/mu9tHLTaxZFLOUiKzjcgWHGIA=
cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY=
cloud.google.com/go/kms v1.20.4 h1:CJ0hMpOg1ANN9tx/a/GPJ+Uxudy8k6f3fvGFuTHiE5A=
cloud.google.com/go/kms v1.20.4/go.mod h1:gPLsp1r4FblUgBYPOcvI/bUPpdMg2Jm1ZVKU4tQUfcc=
cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0MK+hc=
cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI=
cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM=
cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM=
cloud.google.com/go/kms v1.21.1 h1:r1Auo+jlfJSf8B7mUnVw5K0fI7jWyoUy65bV53VjKyk=
cloud.google.com/go/kms v1.21.1/go.mod h1:s0wCyByc9LjTdCjG88toVs70U9W+cc6RKFc8zAqX7nE=
cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q=
cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
@ -21,18 +20,18 @@ github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjq
github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 h1:B/dfvscEQtew9dVuoxqxrUKKv8Ih2f55PydknDamU+g=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0/go.mod h1:fiPSssYvltE08HJchL04dOy+RD4hgrjph0cwGGMntdI=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1 h1:DSDNVxqkoXJiko6x8a90zidoYqnYYa6c1MTzDKzKkTo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2 h1:F0gBpfdPLGsw+nsgk6aqqkZS1jiixa5WwFe3fk/T3Ys=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.2/go.mod h1:SqINnQ9lVVdRlyC8cd1lCI0SdX4n2paeABd2K8ggfnE=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0 h1:7rKG7UmnrxX4N53TFhkYqjc+kVUZuw0fL8I3Fh+Ld9E=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.0/go.mod h1:Wjo+24QJVhhl/L7jy6w9yzFF2yDOf3cKECAa8ecf9vE=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0 h1:eXnN9kaS8TiDwXjoie3hMRLuwdUBUMW9KRgOqB3mCaw=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.0/go.mod h1:XIpam8wumeZ5rVMuhdDQLMfIPDf1WO3IzrCRO3e3e3o=
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1 h1:gUDtaZk8heteyfdmv+pcfHvhR9llnh7c7GMwZ8RVG04=
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.1/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4=
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
@ -53,36 +52,38 @@ github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4u
github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.32.8 h1:cZV+NUS/eGxKXMtmyhtYPJ7Z4YLoI/V8bkTdRZfYhGo=
github.com/aws/aws-sdk-go-v2 v1.32.8/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
github.com/aws/aws-sdk-go-v2/config v1.28.10 h1:fKODZHfqQu06pCzR69KJ3GuttraRJkhlC8g80RZ0Dfg=
github.com/aws/aws-sdk-go-v2/config v1.28.10/go.mod h1:PvdxRYZ5Um9QMq9PQ0zHHNdtKK+he2NHtFCUFMXWXeg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.51 h1:F/9Sm6Y6k4LqDesZDPJCLxQGXNNHd/ZtJiWd0lCZKRk=
github.com/aws/aws-sdk-go-v2/credentials v1.17.51/go.mod h1:TKbzCHm43AoPyA+iLGGcruXd4AFhF8tOmLex2R9jWNQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23 h1:IBAoD/1d8A8/1aA8g4MBVtTRHhXRiNAgwdbo/xRM2DI=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23/go.mod h1:vfENuCM7dofkgKpYzuzf1VT1UKkA/YL3qanfBn7HCaA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27 h1:jSJjSBzw8VDIbWv+mmvBSP8ezsztMYJGH+eKqi9AmNs=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27/go.mod h1:/DAhLbFRgwhmvJdOfSm+WwikZrCuUJiA4WgJG0fTNSw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27 h1:l+X4K77Dui85pIj5foXDhPlnqcNRG2QUyvca300lXh8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27/go.mod h1:KvZXSFEXm6x84yE8qffKvT3x8J5clWnVFXphpohhzJ8=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8 h1:cWno7lefSH6Pp+mSznagKCgfDGeZRin66UvYUqAkyeA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8/go.mod h1:tPD+VjU3ABTBoEJ3nctu5Nyg4P4yjqSH5bJGGkY4+XE=
github.com/aws/aws-sdk-go-v2/service/kms v1.37.8 h1:KbLZjYqhQ9hyB4HwXiheiflTlYQa0+Fz0Ms/rh5f3mk=
github.com/aws/aws-sdk-go-v2/service/kms v1.37.8/go.mod h1:ANs9kBhK4Ghj9z1W+bsr3WsNaPF71qkgd6eE6Ekol/Y=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.9 h1:YqtxripbjWb2QLyzRK9pByfEDvgg95gpC2AyDq4hFE8=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.9/go.mod h1:lV8iQpg6OLOfBnqbGMBKYjilBlf633qwHnBEiMSPoHY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8 h1:6dBT1Lz8fK11m22R+AqfRsFn8320K0T5DTGxxOQBSMw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8/go.mod h1:/kiBvRQXBc6xeJTYzhSdGvJ5vm1tjaDEjH+MSeRJnlY=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 h1:VwhTrsTuVn52an4mXx29PqRzs2Dvu921NpGk7y43tAM=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.6/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc=
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/config v1.29.10 h1:yNjgjiGBp4GgaJrGythyBXg2wAs+Im9fSWIUwvi1CAc=
github.com/aws/aws-sdk-go-v2/config v1.29.10/go.mod h1:A0mbLXSdtob/2t59n1X0iMkPQ5d+YzYZB4rwu7SZ7aA=
github.com/aws/aws-sdk-go-v2/credentials v1.17.63 h1:rv1V3kIJ14pdmTu01hwcMJ0WAERensSiD9rEWEBb1Tk=
github.com/aws/aws-sdk-go-v2/credentials v1.17.63/go.mod h1:EJj+yDf0txT26Ulo0VWTavBl31hOsaeuMxIHu2m0suY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/kms v1.38.1 h1:tecq7+mAav5byF+Mr+iONJnCBf4B4gon8RSp4BrweSc=
github.com/aws/aws-sdk-go-v2/service/kms v1.38.1/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.1 h1:8JdC7Gr9NROg1Rusk25IcZeTO59zLxsKgE0gkh5O6h0=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.1/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.2 h1:wK8O+j2dOolmpNVY1EWIbLgxrGCHJKVPm08Hv/u80M8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.2/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 h1:PZV5W8yk4OtH1JAuhV2PXwwO9v5G5Aoj+eMCn4T+1Kc=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.17/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
@ -95,22 +96,32 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY=
github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3 h1:hx6E25SvI2WiZdt/gxINcYBnHD7PE2Vr9auqwg5B05g=
github.com/charmbracelet/glamour v0.9.2-0.20250319212134-549f544650e3/go.mod h1:ihVqv4/YOY5Fweu1cxajuQrwJFh3zU4Ukb4mHVNjq3s=
github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5 h1:uOnMxWghHfEYm2DPMeIHHAEirV/TduBVC9ZRXGcX9Q8=
github.com/charmbracelet/huh v0.6.1-0.20250409210615-c5906631cbb5/go.mod h1:xl27E/xNaX3WwdkqpvBwjJcGWhupkU52CWLC5hReBTw=
github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc h1:nFRtCfZu/zkltd2lsLUPlVNv3ej/Atod9hcdbRZtlys=
github.com/charmbracelet/lipgloss v1.1.1-0.20250319133953-166f707985bc/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
@ -131,7 +142,6 @@ github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUo
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
@ -160,6 +170,10 @@ github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBi
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
@ -167,8 +181,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
@ -177,8 +191,6 @@ github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/
github.com/gdamore/tcell/v2 v2.5.4/go.mod h1:dZgRy5v4iMobMEcWNYBtREnDZAT9DYmfqIkrgEMxLyw=
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@ -188,8 +200,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=
github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w=
github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE=
github.com/go-openapi/errors v0.22.1 h1:kslMRRnK7NCb/CvR1q1VWuEQCEIsBGn5GgKD9e+HYhU=
github.com/go-openapi/errors v0.22.1/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
@ -202,32 +214,34 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c=
github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/certificate-transparency-go v1.3.1 h1:akbcTfQg0iZlANZLn0L9xOeWtyCIdeoYhKrqi5iH3Go=
github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-containerregistry v0.20.3 h1:oNx7IdTI936V8CQRveCjaxOiegWwvM7kqkbXTpyiovI=
github.com/google/go-containerregistry v0.20.3/go.mod h1:w00pIgBRDVUDFM6bq+Qx8lwNWK+cxgCuX1vd3PIBDNI=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w=
@ -236,8 +250,8 @@ github.com/google/trillian v1.7.1 h1:+zX8jLM3524bAMPS+VxaDIDgsMv3/ty6DuLWerHXcek
github.com/google/trillian v1.7.1/go.mod h1:E1UMAHqpZCA8AQdrKdWmHmtUfSeiD0sDWD1cv00Xa+c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@ -265,13 +279,12 @@ github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0S
github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw=
github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA=
github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8=
github.com/hashicorp/vault/api v1.16.0 h1:nbEYGJiAPGzT9U4oWgaaB0g+Rj8E59QuHKyA5LhwQN4=
github.com/hashicorp/vault/api v1.16.0/go.mod h1:KhuUhzOD8lDSk29AtzNjgAu2kxRA9jL9NAbkFlqvkBA=
github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU=
github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@ -330,16 +343,16 @@ github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec h1:2tTW6cDth2T
github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec/go.mod h1:TmwEoGCwIti7BCeJ9hescZgRtatxRE+A72pCoPfmcfk=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@ -356,10 +369,16 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
@ -378,8 +397,8 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -387,12 +406,12 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d h1:jKIUJdMcIVGOSHi6LSqJqw9RqblyblE2ZrHvFbWR3S0=
@ -409,10 +428,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A=
github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk=
github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4=
@ -429,51 +446,44 @@ github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc h1:vH0NQbIDk+mJL
github.com/shurcooL/githubv4 v0.0.0-20240120211514-18a1ae0e79dc/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
github.com/sigstore/protobuf-specs v0.3.3 h1:RMZQgXTD/pF7KW6b5NaRLYxFYZ/wzx44PQFXN2PEo5g=
github.com/sigstore/protobuf-specs v0.3.3/go.mod h1:vIhZ6Uor1a38+wvRrKcqL2PtYNlgoIW9lhzYzkyy4EU=
github.com/sigstore/rekor v1.3.8 h1:B8kJI8mpSIXova4Jxa6vXdJyysRxFGsEsLKBDl0rRjA=
github.com/sigstore/rekor v1.3.8/go.mod h1:/dHFYKSuxEygfDRnEwyJ+ZD6qoVYNXQdi1mJrKvKWsI=
github.com/sigstore/sigstore v1.8.12 h1:S8xMVZbE2z9ZBuQUEG737pxdLjnbOIcFi5v9UFfkJFc=
github.com/sigstore/sigstore v1.8.12/go.mod h1:+PYQAa8rfw0QdPpBcT+Gl3egKD9c+TUgAlF12H3Nmjo=
github.com/sigstore/sigstore-go v0.7.0 h1:bIGPc2IbnbxnzlqQcKlh1o96bxVJ4yRElpP1gHrOH48=
github.com/sigstore/sigstore-go v0.7.0/go.mod h1:4RrCK+i+jhx7lyOG2Vgef0/kFLbKlDI1hrioUYvkxxA=
github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.12 h1:EC3UmIaa7nV9sCgSpVevmvgvTYTkMqyrRbj5ojPp7tE=
github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.12/go.mod h1:aw60vs3crnQdM/DYH+yF2P0MVKtItwAX34nuaMrY7Lk=
github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.12 h1:FPpliDTywSy0woLHMAdmTSZ5IS/lVBZ0dY0I+2HmnSY=
github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.12/go.mod h1:NkPiz4XA0JcBSXzJUrjMj7Xi7oSTew1Ip3Zmt56mHlw=
github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.12 h1:kweBChR6M9FEvmxN3BMEcl7SNnwxTwKF7THYFKLOE5U=
github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.12/go.mod h1:6+d+A6oYt1W5OgtzgEVb21V7tAZ/C2Ihtzc5MNJbayY=
github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.12 h1:jvY1B9bjP+tKzdKDyuq5K7O19CG2IKzGJNTy5tuL2Gs=
github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.12/go.mod h1:2uEeOb8xE2RC6OvzxKux1wkS39Zv8gA27z92m49xUTc=
github.com/sigstore/timestamp-authority v1.2.4 h1:RjXZxOWorEiem/uSr0pFHVtQpyzpcFxgugo5jVqm3mw=
github.com/sigstore/timestamp-authority v1.2.4/go.mod h1:ExrbobKdEuwuBptZIiKp1IaVBRiUeKbiuSyZTO8Okik=
github.com/sigstore/protobuf-specs v0.4.1 h1:5SsMqZbdkcO/DNHudaxuCUEjj6x29tS2Xby1BxGU7Zc=
github.com/sigstore/protobuf-specs v0.4.1/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc=
github.com/sigstore/rekor v1.3.9 h1:sUjRpKVh/hhgqGMs0t+TubgYsksArZ6poLEC3MsGAzU=
github.com/sigstore/rekor v1.3.9/go.mod h1:xThNUhm6eNEmkJ/SiU/FVU7pLY2f380fSDZFsdDWlcM=
github.com/sigstore/sigstore v1.9.1 h1:bNMsfFATsMPaagcf+uppLk4C9rQZ2dh5ysmCxQBYWaw=
github.com/sigstore/sigstore v1.9.1/go.mod h1:zUoATYzR1J3rLNp3jmp4fzIJtWdhC3ZM6MnpcBtnsE4=
github.com/sigstore/sigstore-go v0.7.2 h1:CN4xPasChSEb0QBMxMW5dLcXdA9KD4QiRyVnMkhXj6U=
github.com/sigstore/sigstore-go v0.7.2/go.mod h1:AIRj4I3LC82qd07VFm3T2zXYiddxeBV1k/eoS8nTz0E=
github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.1 h1:/YcNq687WnXpIRXl04nLfJX741G4iW+w+7Nem2Zy0f4=
github.com/sigstore/sigstore/pkg/signature/kms/aws v1.9.1/go.mod h1:ApL9RpKsi7gkSYN0bMNdm/3jZ9EefxMmfYHfUmq2ZYM=
github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.1 h1:FnusXyTIInnwfIOzzl5PFilRm1I97dxMSOcCkZBu9Kc=
github.com/sigstore/sigstore/pkg/signature/kms/azure v1.9.1/go.mod h1:d5m5LOa/69a+t2YC9pDPwS1n2i/PhqB4cUKbpVDlKKE=
github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.1 h1:LFiYK1DEWQ6Hf/nroFzBMM+s5rVSjVL45Alpb5Ctl5A=
github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.9.1/go.mod h1:GFyFmDsE2wDuIHZD+4+JErGpA0S4zJsKNz5l2JVJd8s=
github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.1 h1:sIW6xe4yU5eIMH8fve2C78d+r29KmHnIb+7po+80bsY=
github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.9.1/go.mod h1:3pNf99GnK9eu3XUa5ebHzgEQSVYf9hqAoPFwbwD6O6M=
github.com/sigstore/timestamp-authority v1.2.5 h1:W22JmwRv1Salr/NFFuP7iJuhytcZszQjldoB8GiEdnw=
github.com/sigstore/timestamp-authority v1.2.5/go.mod h1:gWPKWq4HMWgPCETre0AakgBzcr9DRqHrsgbrRqsigOs=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@ -484,6 +494,12 @@ github.com/theupdateframework/go-tuf/v2 v2.0.2 h1:PyNnjV9BJNzN1ZE6BcWK+5JbF+if37
github.com/theupdateframework/go-tuf/v2 v2.0.2/go.mod h1:baB22nBHeHBCeuGZcIlctNq4P61PcOdyARlplg5xmLA=
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8=
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI=
github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis=
github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0=
github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw=
github.com/tink-crypto/tink-go/v2 v2.3.0 h1:4/TA0lw0lA/iVKBL9f8R5eP7397bfc4antAMXF5JRhs=
github.com/tink-crypto/tink-go/v2 v2.3.0/go.mod h1:kfPOtXIadHlekBTeBtJrHWqoGL+Fm3JQg0wtltPuxLU=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0=
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs=
github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4=
@ -504,22 +520,22 @@ go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
go.step.sm/crypto v0.57.0 h1:YjoRQDaJYAxHLVwjst0Bl0xcnoKzVwuHCJtEo2VSHYU=
go.step.sm/crypto v0.57.0/go.mod h1:+Lwp5gOVPaTa3H/Ul/TzGbxQPXZZcKIUGMS0lG6n9Go=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.step.sm/crypto v0.60.0 h1:UgSw8DFG5xUOGB3GUID17UA32G4j1iNQ4qoMhBmsVFw=
go.step.sm/crypto v0.60.0/go.mod h1:Ep83Lv818L4gV0vhFTdPWRKnL6/5fRMpi8SaoP5ArSw=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@ -528,73 +544,72 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.216.0 h1:xnEHy+xWFrtYInWPy8OdGFsyIfWJjtVnO39g7pz2BFY=
google.golang.org/api v0.216.0/go.mod h1:K9wzQMvWi47Z9IU7OgdOofvZuw75Ge3PPITImZR/UyI=
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk=
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4=
google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A=
google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/api v0.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc=
google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ=
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE=
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE=
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -17,6 +17,7 @@ import (
const (
aliasesKey = "aliases"
browserKey = "browser"
colorLabelsKey = "color_labels"
editorKey = "editor"
gitProtocolKey = "git_protocol"
hostsKey = "hosts"
@ -113,6 +114,11 @@ func (c *cfg) Browser(hostname string) gh.ConfigEntry {
return c.GetOrDefault(hostname, browserKey).Unwrap()
}
func (c *cfg) ColorLabels(hostname string) gh.ConfigEntry {
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
return c.GetOrDefault(hostname, colorLabelsKey).Unwrap()
}
func (c *cfg) Editor(hostname string) gh.ConfigEntry {
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
return c.GetOrDefault(hostname, editorKey).Unwrap()
@ -532,6 +538,8 @@ aliases:
http_unix_socket:
# What web browser gh should use when opening URLs. If blank, will refer to environment.
browser:
# Whether to display labels using their RGB hex color codes in terminals that support truecolor. Supported values: enabled, disabled
color_labels: disabled
`
type ConfigOption struct {
@ -602,6 +610,15 @@ var Options = []ConfigOption{
return c.Browser(hostname).Value
},
},
{
Key: colorLabelsKey,
Description: "whether to display labels using their RGB hex color codes in terminals that support truecolor",
DefaultValue: "disabled",
AllowedValues: []string{"enabled", "disabled"},
CurrentValue: func(c gh.Config, hostname string) string {
return c.ColorLabels(hostname).Value
},
},
}
func HomeDirPath(subdir string) (string, error) {

View file

@ -32,6 +32,7 @@ func TestNewConfigProvidesFallback(t *testing.T) {
requireKeyWithValue(t, spiedCfg, []string{aliasesKey, "co"}, "pr checkout")
requireKeyWithValue(t, spiedCfg, []string{httpUnixSocketKey}, "")
requireKeyWithValue(t, spiedCfg, []string{browserKey}, "")
requireKeyWithValue(t, spiedCfg, []string{colorLabelsKey}, "disabled")
}
func TestGetOrDefaultApplicationDefaults(t *testing.T) {
@ -137,6 +138,7 @@ func TestFallbackConfig(t *testing.T) {
requireKeyWithValue(t, cfg, []string{aliasesKey, "co"}, "pr checkout")
requireKeyWithValue(t, cfg, []string{httpUnixSocketKey}, "")
requireKeyWithValue(t, cfg, []string{browserKey}, "")
requireKeyWithValue(t, cfg, []string{colorLabelsKey}, "disabled")
requireNoKey(t, cfg, []string{"unknown"})
}

View file

@ -55,6 +55,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock {
mock.BrowserFunc = func(hostname string) gh.ConfigEntry {
return cfg.Browser(hostname)
}
mock.ColorLabelsFunc = func(hostname string) gh.ConfigEntry {
return cfg.ColorLabels(hostname)
}
mock.EditorFunc = func(hostname string) gh.ConfigEntry {
return cfg.Editor(hostname)
}

View file

@ -37,6 +37,8 @@ type Config interface {
// Browser returns the configured browser, optionally scoped by host.
Browser(hostname string) ConfigEntry
// ColorLabels returns the configured color_label setting, optionally scoped by host.
ColorLabels(hostname string) ConfigEntry
// Editor returns the configured editor, optionally scoped by host.
Editor(hostname string) ConfigEntry
// GitProtocol returns the configured git protocol, optionally scoped by host.

View file

@ -31,6 +31,9 @@ var _ gh.Config = &ConfigMock{}
// CacheDirFunc: func() string {
// panic("mock out the CacheDir method")
// },
// ColorLabelsFunc: func(hostname string) gh.ConfigEntry {
// panic("mock out the ColorLabels method")
// },
// EditorFunc: func(hostname string) gh.ConfigEntry {
// panic("mock out the Editor method")
// },
@ -83,6 +86,9 @@ type ConfigMock struct {
// CacheDirFunc mocks the CacheDir method.
CacheDirFunc func() string
// ColorLabelsFunc mocks the ColorLabels method.
ColorLabelsFunc func(hostname string) gh.ConfigEntry
// EditorFunc mocks the Editor method.
EditorFunc func(hostname string) gh.ConfigEntry
@ -132,6 +138,11 @@ type ConfigMock struct {
// CacheDir holds details about calls to the CacheDir method.
CacheDir []struct {
}
// ColorLabels holds details about calls to the ColorLabels method.
ColorLabels []struct {
// Hostname is the hostname argument value.
Hostname string
}
// Editor holds details about calls to the Editor method.
Editor []struct {
// Hostname is the hostname argument value.
@ -194,6 +205,7 @@ type ConfigMock struct {
lockAuthentication sync.RWMutex
lockBrowser sync.RWMutex
lockCacheDir sync.RWMutex
lockColorLabels sync.RWMutex
lockEditor sync.RWMutex
lockGetOrDefault sync.RWMutex
lockGitProtocol sync.RWMutex
@ -320,6 +332,38 @@ func (mock *ConfigMock) CacheDirCalls() []struct {
return calls
}
// ColorLabels calls ColorLabelsFunc.
func (mock *ConfigMock) ColorLabels(hostname string) gh.ConfigEntry {
if mock.ColorLabelsFunc == nil {
panic("ConfigMock.ColorLabelsFunc: method is nil but Config.ColorLabels was just called")
}
callInfo := struct {
Hostname string
}{
Hostname: hostname,
}
mock.lockColorLabels.Lock()
mock.calls.ColorLabels = append(mock.calls.ColorLabels, callInfo)
mock.lockColorLabels.Unlock()
return mock.ColorLabelsFunc(hostname)
}
// ColorLabelsCalls gets all the calls that were made to ColorLabels.
// Check the length with:
//
// len(mockedConfig.ColorLabelsCalls())
func (mock *ConfigMock) ColorLabelsCalls() []struct {
Hostname string
} {
var calls []struct {
Hostname string
}
mock.lockColorLabels.RLock()
calls = mock.calls.ColorLabels
mock.lockColorLabels.RUnlock()
return calls
}
// Editor calls EditorFunc.
func (mock *ConfigMock) Editor(hostname string) gh.ConfigEntry {
if mock.EditorFunc == nil {

View file

@ -0,0 +1,493 @@
//go:build !windows
package prompter_test
import (
"fmt"
"io"
"strings"
"testing"
"time"
"github.com/Netflix/go-expect"
"github.com/cli/cli/v2/internal/prompter"
"github.com/creack/pty"
"github.com/hinshun/vt10x"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The following tests are broadly testing the accessible prompter, and NOT asserting
// on the prompter's complete and exact output strings.
//
// These tests generally operate with this logic:
// - Wait for a particular substring (a portion of the prompt) to appear
// - Send input
// - Wait for another substring to appear or for control to return to the test
// - Assert that the input value was returned from the prompter function
// In the future, expanding these tests to assert on the exact prompt strings
// would help build confidence in `huh` upgrades, but for now these tests
// are sufficient to ensure that the accessible prompter behaves roughly as expected
// but doesn't mandate that prompts always look exactly the same.
func TestAccessiblePrompter(t *testing.T) {
t.Run("Select", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Choose:")
require.NoError(t, err)
// Select option 1
_, err = console.SendLine("1")
require.NoError(t, err)
}()
selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"})
require.NoError(t, err)
assert.Equal(t, 0, selectValue)
})
t.Run("MultiSelect", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Select a number")
require.NoError(t, err)
// Select options 1 and 2
_, err = console.SendLine("1")
require.NoError(t, err)
_, err = console.SendLine("2")
require.NoError(t, err)
// This confirms selections
_, err = console.SendLine("0")
require.NoError(t, err)
}()
multiSelectValue, err := p.MultiSelect("Select a number", []string{}, []string{"1", "2", "3"})
require.NoError(t, err)
assert.Equal(t, []int{0, 1}, multiSelectValue)
})
t.Run("Input", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
dummyText := "12345abcdefg"
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Enter some characters")
require.NoError(t, err)
// Enter a number
_, err = console.SendLine(dummyText)
require.NoError(t, err)
}()
inputValue, err := p.Input("Enter some characters", "")
require.NoError(t, err)
assert.Equal(t, dummyText, inputValue)
})
t.Run("Input - blank input returns default value", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
dummyDefaultValue := "12345abcdefg"
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Enter some characters")
require.NoError(t, err)
// Enter nothing
_, err = console.SendLine("")
require.NoError(t, err)
}()
inputValue, err := p.Input("Enter some characters", dummyDefaultValue)
require.NoError(t, err)
assert.Equal(t, dummyDefaultValue, inputValue)
})
t.Run("Password", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
dummyPassword := "12345abcdefg"
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Enter password")
require.NoError(t, err)
// Enter a number
_, err = console.SendLine(dummyPassword)
require.NoError(t, err)
}()
passwordValue, err := p.Password("Enter password")
require.NoError(t, err)
require.Equal(t, dummyPassword, passwordValue)
})
t.Run("Confirm", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Are you sure")
require.NoError(t, err)
// Confirm
_, err = console.SendLine("y")
require.NoError(t, err)
}()
confirmValue, err := p.Confirm("Are you sure", false)
require.NoError(t, err)
require.Equal(t, true, confirmValue)
})
t.Run("Confirm - blank input returns default", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Are you sure")
require.NoError(t, err)
// Enter nothing
_, err = console.SendLine("")
require.NoError(t, err)
}()
confirmValue, err := p.Confirm("Are you sure", false)
require.NoError(t, err)
require.Equal(t, false, confirmValue)
})
t.Run("AuthToken", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
dummyAuthToken := "12345abcdefg"
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Paste your authentication token:")
require.NoError(t, err)
// Enter some dummy auth token
_, err = console.SendLine(dummyAuthToken)
require.NoError(t, err)
}()
authValue, err := p.AuthToken()
require.NoError(t, err)
require.Equal(t, dummyAuthToken, authValue)
})
t.Run("AuthToken - blank input returns error", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
dummyAuthTokenForAfterFailure := "12345abcdefg"
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Paste your authentication token:")
require.NoError(t, err)
// Enter nothing
_, err = console.SendLine("")
require.NoError(t, err)
// Expect an error message
_, err = console.ExpectString("token is required")
require.NoError(t, err)
// Now enter some dummy auth token to return control back to the test
_, err = console.SendLine(dummyAuthTokenForAfterFailure)
require.NoError(t, err)
}()
authValue, err := p.AuthToken()
require.NoError(t, err)
require.Equal(t, dummyAuthTokenForAfterFailure, authValue)
})
t.Run("ConfirmDeletion", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
requiredValue := "test"
go func() {
// Wait for prompt to appear
_, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue))
require.NoError(t, err)
// Confirm
_, err = console.SendLine(requiredValue)
require.NoError(t, err)
}()
// An err indicates that the confirmation text sent did not match
err := p.ConfirmDeletion(requiredValue)
require.NoError(t, err)
})
t.Run("ConfirmDeletion - bad input", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
requiredValue := "test"
badInputValue := "garbage"
go func() {
// Wait for prompt to appear
_, err := console.ExpectString(fmt.Sprintf("Type %q to confirm deletion", requiredValue))
require.NoError(t, err)
// Confirm with bad input
_, err = console.SendLine(badInputValue)
require.NoError(t, err)
// Expect an error message and loop back to the prompt
_, err = console.ExpectString(fmt.Sprintf("You entered: %q", badInputValue))
require.NoError(t, err)
// Confirm with the correct input to return control back to the test
_, err = console.SendLine(requiredValue)
require.NoError(t, err)
}()
// An err indicates that the confirmation text sent did not match
err := p.ConfirmDeletion(requiredValue)
require.NoError(t, err)
})
t.Run("InputHostname", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
hostname := "example.com"
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Hostname:")
require.NoError(t, err)
// Enter the hostname
_, err = console.SendLine(hostname)
require.NoError(t, err)
}()
inputValue, err := p.InputHostname()
require.NoError(t, err)
require.Equal(t, hostname, inputValue)
})
t.Run("MarkdownEditor - blank allowed with blank input returns blank", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("How to edit?")
require.NoError(t, err)
// Enter 2, to select "skip"
_, err = console.SendLine("2")
require.NoError(t, err)
}()
inputValue, err := p.MarkdownEditor("How to edit?", "", true)
require.NoError(t, err)
require.Equal(t, "", inputValue)
})
t.Run("MarkdownEditor - blank disallowed with default value returns default value", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
defaultValue := "12345abcdefg"
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("How to edit?")
require.NoError(t, err)
// Enter number 2 to select "skip". This shouldn't be allowed.
_, err = console.SendLine("2")
require.NoError(t, err)
// Expect a notice to enter something valid since blank is disallowed.
_, err = console.ExpectString("invalid input. please try again")
require.NoError(t, err)
// Send a 1 to select to open the editor. This will immediately exit
_, err = console.SendLine("1")
require.NoError(t, err)
}()
inputValue, err := p.MarkdownEditor("How to edit?", defaultValue, false)
require.NoError(t, err)
require.Equal(t, defaultValue, inputValue)
})
t.Run("MarkdownEditor - blank disallowed no default value returns error", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestAcessiblePrompter(t, console)
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("How to edit?")
require.NoError(t, err)
// Enter number 2 to select "skip". This shouldn't be allowed.
_, err = console.SendLine("2")
require.NoError(t, err)
// Expect a notice to enter something valid since blank is disallowed.
_, err = console.ExpectString("invalid input. please try again")
require.NoError(t, err)
// Send a 1 to select to open the editor since skip is invalid and
// we need to return control back to the test.
_, err = console.SendLine("1")
require.NoError(t, err)
}()
inputValue, err := p.MarkdownEditor("How to edit?", "", false)
require.NoError(t, err)
require.Equal(t, "", inputValue)
})
}
func TestSurveyPrompter(t *testing.T) {
// This not a comprehensive test of the survey prompter, but it does
// demonstrate that the survey prompter is used when the
// accessible prompter is disabled.
t.Run("Select uses survey prompter when accessible prompter is disabled", func(t *testing.T) {
console := newTestVirtualTerminal(t)
p := newTestSurveyPrompter(t, console)
go func() {
// Wait for prompt to appear
_, err := console.ExpectString("Select a number")
require.NoError(t, err)
// Send a newline to select the first option
// Note: This would not work with the accessible prompter
// because it would requires sending a 1 to select the first option.
// So it proves we are seeing a survey prompter.
_, err = console.SendLine("")
require.NoError(t, err)
}()
selectValue, err := p.Select("Select a number", "", []string{"1", "2", "3"})
require.NoError(t, err)
assert.Equal(t, 0, selectValue)
})
}
func newTestVirtualTerminal(t *testing.T) *expect.Console {
t.Helper()
// Create a PTY and hook up a virtual terminal emulator
ptm, pts, err := pty.Open()
require.NoError(t, err)
term := vt10x.New(vt10x.WithWriter(pts))
// Create a console via Expect that allows scripting against the terminal
consoleOpts := []expect.ConsoleOpt{
expect.WithStdin(ptm),
expect.WithStdout(term),
expect.WithCloser(ptm, pts),
failOnExpectError(t),
failOnSendError(t),
expect.WithDefaultTimeout(time.Second),
}
console, err := expect.NewConsole(consoleOpts...)
require.NoError(t, err)
t.Cleanup(func() { testCloser(t, console) })
return console
}
func newTestAcessiblePrompter(t *testing.T, console *expect.Console) prompter.Prompter {
t.Helper()
t.Setenv("GH_ACCESSIBLE_PROMPTER", "true")
// `echo`` is chose as the editor command because it immediately returns
// a success exit code, returns an empty string, doesn't require any user input,
// and since this file is only built on Linux, it is near guaranteed to be available.
return prompter.New("echo", console.Tty(), console.Tty(), console.Tty())
}
func newTestSurveyPrompter(t *testing.T, console *expect.Console) prompter.Prompter {
t.Helper()
t.Setenv("GH_ACCESSIBLE_PROMPTER", "false")
return prompter.New("echo", console.Tty(), console.Tty(), console.Tty())
}
// failOnExpectError adds an observer that will fail the test in a standardised way
// if any expectation on the command output fails, without requiring an explicit
// assertion.
//
// Use WithRelaxedIO to disable this behaviour.
func failOnExpectError(t *testing.T) expect.ConsoleOpt {
t.Helper()
return expect.WithExpectObserver(
func(matchers []expect.Matcher, buf string, err error) {
t.Helper()
if err == nil {
return
}
if len(matchers) == 0 {
t.Fatalf("Error occurred while matching %q: %s\n", buf, err)
}
var criteria []string
for _, matcher := range matchers {
criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria()))
}
t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err)
},
)
}
// failOnSendError adds an observer that will fail the test in a standardised way
// if any sending of input fails, without requiring an explicit assertion.
//
// Use WithRelaxedIO to disable this behaviour.
func failOnSendError(t *testing.T) expect.ConsoleOpt {
t.Helper()
return expect.WithSendObserver(
func(msg string, n int, err error) {
t.Helper()
if err != nil {
t.Fatalf("Failed to send %q: %s\n", msg, err)
}
if len(msg) != n {
t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg)
}
},
)
}
// testCloser is a helper to fail the test if a Closer fails to close.
func testCloser(t *testing.T, closer io.Closer) {
t.Helper()
if err := closer.Close(); err != nil {
t.Errorf("Close failed: %s", err)
}
}

View file

@ -2,9 +2,12 @@ package prompter
import (
"fmt"
"os"
"slices"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/charmbracelet/huh"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/pkg/surveyext"
ghPrompter "github.com/cli/go-gh/v2/pkg/prompter"
@ -13,20 +16,46 @@ import (
//go:generate moq -rm -out prompter_mock.go . Prompter
type Prompter interface {
// generic prompts from go-gh
Select(string, string, []string) (int, error)
// Select prompts the user to select an option from a list of options.
Select(prompt string, defaultValue string, options []string) (int, error)
// MultiSelect prompts the user to select one or more options from a list of options.
MultiSelect(prompt string, defaults []string, options []string) ([]int, error)
Input(string, string) (string, error)
Password(string) (string, error)
Confirm(string, bool) (bool, error)
// Input prompts the user to enter a string value.
Input(prompt string, defaultValue string) (string, error)
// Password prompts the user to enter a password.
Password(prompt string) (string, error)
// Confirm prompts the user to confirm an action.
Confirm(prompt string, defaultValue bool) (bool, error)
// gh specific prompts
// AuthToken prompts the user to enter an authentication token.
AuthToken() (string, error)
ConfirmDeletion(string) error
// ConfirmDeletion prompts the user to confirm deletion of a resource by
// typing the requiredValue.
ConfirmDeletion(requiredValue string) error
// InputHostname prompts the user to enter a hostname.
InputHostname() (string, error)
MarkdownEditor(string, string, bool) (string, error)
// MarkdownEditor prompts the user to edit a markdown document in an editor.
// If blankAllowed is true, the user can skip the editor and an empty string
// will be returned.
MarkdownEditor(prompt string, defaultValue string, blankAllowed bool) (string, error)
}
func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWriter, stderr ghPrompter.FileWriter) Prompter {
accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER")
falseyValues := []string{"false", "0", "no", ""}
if accessiblePrompterIsSet && !slices.Contains(falseyValues, accessiblePrompterValue) {
return &accessiblePrompter{
stdin: stdin,
stdout: stdout,
stderr: stderr,
editorCmd: editorCmd,
}
}
return &surveyPrompter{
prompter: ghPrompter.New(stdin, stdout, stderr),
stdin: stdin,
@ -36,6 +65,209 @@ func New(editorCmd string, stdin ghPrompter.FileReader, stdout ghPrompter.FileWr
}
}
type accessiblePrompter struct {
stdin ghPrompter.FileReader
stdout ghPrompter.FileWriter
stderr ghPrompter.FileWriter
editorCmd string
}
func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form {
return huh.NewForm(groups...).
WithTheme(huh.ThemeBase16()).
WithAccessible(true).
WithInput(p.stdin).
WithOutput(p.stdout)
}
func (p *accessiblePrompter) Select(prompt, _ string, options []string) (int, error) {
var result int
formOptions := []huh.Option[int]{}
for i, o := range options {
formOptions = append(formOptions, huh.NewOption(o, i))
}
form := p.newForm(
huh.NewGroup(
huh.NewSelect[int]().
Title(prompt).
Value(&result).
Options(formOptions...),
),
)
err := form.Run()
return result, err
}
func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) {
var result []int
formOptions := make([]huh.Option[int], len(options))
for i, o := range options {
formOptions[i] = huh.NewOption(o, i)
}
form := p.newForm(
huh.NewGroup(
huh.NewMultiSelect[int]().
Title(prompt).
Value(&result).
Limit(len(options)).
Options(formOptions...),
),
)
if err := form.Run(); err != nil {
return nil, err
}
return result, nil
}
func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) {
result := defaultValue
prompt = fmt.Sprintf("%s (%s)", prompt, defaultValue)
form := p.newForm(
huh.NewGroup(
huh.NewInput().
Title(prompt).
Value(&result),
),
)
err := form.Run()
return result, err
}
func (p *accessiblePrompter) Password(prompt string) (string, error) {
var result string
// EchoMode(huh.EchoModePassword) doesn't have any effect in accessible mode.
form := p.newForm(
huh.NewGroup(
huh.NewInput().
Title(prompt).
Value(&result),
),
)
err := form.Run()
if err != nil {
return "", err
}
return result, nil
}
func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) {
result := defaultValue
form := p.newForm(
huh.NewGroup(
huh.NewConfirm().
Title(prompt).
Value(&result),
),
)
if err := form.Run(); err != nil {
return false, err
}
return result, nil
}
func (p *accessiblePrompter) AuthToken() (string, error) {
var result string
form := p.newForm(
huh.NewGroup(
huh.NewInput().
Title("Paste your authentication token:").
// Note: if this validation fails, the prompt loops.
Validate(func(input string) error {
if input == "" {
return fmt.Errorf("token is required")
}
return nil
}).
Value(&result),
// This doesn't have any effect in accessible mode.
// EchoMode(huh.EchoModePassword),
),
)
err := form.Run()
return result, err
}
func (p *accessiblePrompter) ConfirmDeletion(requiredValue string) error {
form := p.newForm(
huh.NewGroup(
huh.NewInput().
Title(fmt.Sprintf("Type %q to confirm deletion", requiredValue)).
Validate(func(input string) error {
if input != requiredValue {
return fmt.Errorf("You entered: %q", input)
}
return nil
}),
),
)
return form.Run()
}
func (p *accessiblePrompter) InputHostname() (string, error) {
var result string
form := p.newForm(
huh.NewGroup(
huh.NewInput().
Title("Hostname:").
Validate(ghinstance.HostnameValidator).
Value(&result),
),
)
err := form.Run()
if err != nil {
return "", err
}
return result, nil
}
func (p *accessiblePrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) {
var result string
skipOption := "skip"
launchOption := "launch"
options := []huh.Option[string]{
huh.NewOption(fmt.Sprintf("Launch %s", surveyext.EditorName(p.editorCmd)), launchOption),
}
if blankAllowed {
options = append(options, huh.NewOption("Skip", skipOption))
}
form := p.newForm(
huh.NewGroup(
huh.NewSelect[string]().
Title(prompt).
Options(options...).
Value(&result),
),
)
if err := form.Run(); err != nil {
return "", err
}
if result == skipOption {
return "", nil
}
// launchOption was selected
text, err := surveyext.Edit(p.editorCmd, "*.md", defaultValue, p.stdin, p.stdout, p.stderr)
if err != nil {
return "", err
}
return text, nil
}
type surveyPrompter struct {
prompter *ghPrompter.Prompter
stdin ghPrompter.FileReader

View file

@ -46,7 +46,7 @@ func Stub() (*CommandStubber, func(T)) {
return
}
t.Helper()
t.Errorf("unmatched stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", "))
t.Errorf("unmatched exec stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", "))
}
}

View file

@ -73,7 +73,7 @@ func NewWithWriter(w io.Writer, isTTY bool, maxWidth int, cs *iostreams.ColorSch
// was not padded. In tests cs.Enabled() is false which allows us to avoid having to fix up
// numerous tests that verify header padding.
var paddingFunc func(int, string) string
if cs.Enabled() {
if cs.Enabled {
paddingFunc = text.PadRight
}

View file

@ -105,7 +105,11 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
config.TrustDomain = td
}
opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config)
sgVerifier, err := verification.NewLiveSigstoreVerifier(config)
if err != nil {
return fmt.Errorf("failed to create Sigstore verifier: %w", err)
}
opts.SigstoreVerifier = sgVerifier
if runF != nil {
return runF(opts)

View file

@ -5,13 +5,17 @@ import (
"testing"
"github.com/sigstore/sigstore-go/pkg/bundle"
sgData "github.com/sigstore/sigstore-go/pkg/testing/data"
)
//go:embed sigstore-js-2.1.0-bundle.json
var SigstoreBundleRaw []byte
// SigstoreBundle returns a test *sigstore.Bundle
// SigstoreBundle returns a test sigstore-go bundle.Bundle
func SigstoreBundle(t *testing.T) *bundle.Bundle {
return sgData.TestBundle(t, SigstoreBundleRaw)
b := &bundle.Bundle{}
err := b.UnmarshalJSON(SigstoreBundleRaw)
if err != nil {
t.Fatalf("failed to unmarshal sigstore bundle: %v", err)
}
return b
}

View file

@ -44,12 +44,11 @@ type SigstoreVerifier interface {
}
type LiveSigstoreVerifier struct {
TrustedRoot string
Logger *io.Handler
NoPublicGood bool
// If tenancy mode is not used, trust domain is empty
TrustDomain string
TUFMetadataDir o.Option[string]
PublicGood *verify.SignedEntityVerifier
GitHub *verify.SignedEntityVerifier
Custom map[string]*verify.SignedEntityVerifier
}
var ErrNoAttestationsVerified = errors.New("no attestations were verified")
@ -57,56 +56,43 @@ var ErrNoAttestationsVerified = errors.New("no attestations were verified")
// NewLiveSigstoreVerifier creates a new LiveSigstoreVerifier struct
// that is used to verify artifacts and attestations against the
// Public Good, GitHub, or a custom trusted root.
func NewLiveSigstoreVerifier(config SigstoreConfig) *LiveSigstoreVerifier {
return &LiveSigstoreVerifier{
TrustedRoot: config.TrustedRoot,
Logger: config.Logger,
NoPublicGood: config.NoPublicGood,
TrustDomain: config.TrustDomain,
TUFMetadataDir: config.TUFMetadataDir,
func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, error) {
liveVerifier := &LiveSigstoreVerifier{
Logger: config.Logger,
NoPublicGood: config.NoPublicGood,
}
}
func getBundleIssuer(b *bundle.Bundle) (string, error) {
if !b.MinVersion("0.2") {
return "", fmt.Errorf("unsupported bundle version: %s", b.MediaType)
}
verifyContent, err := b.VerificationContent()
if err != nil {
return "", fmt.Errorf("failed to get bundle verification content: %v", err)
}
leafCert := verifyContent.Certificate()
if leafCert == nil {
return "", fmt.Errorf("leaf cert not found")
}
if len(leafCert.Issuer.Organization) != 1 {
return "", fmt.Errorf("expected the leaf certificate issuer to only have one organization")
}
return leafCert.Issuer.Organization[0], nil
}
func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) {
// if no custom trusted root is set, attempt to create a Public Good or
// GitHub Sigstore verifier
if v.TrustedRoot == "" {
switch issuer {
case PublicGoodIssuerOrg:
if v.NoPublicGood {
return nil, fmt.Errorf("detected public good instance but requested verification without public good instance")
}
return newPublicGoodVerifier(v.TUFMetadataDir)
case GitHubIssuerOrg:
return newGitHubVerifier(v.TrustDomain, v.TUFMetadataDir)
default:
return nil, fmt.Errorf("leaf certificate issuer is not recognized")
// if a custom trusted root is set, configure custom verifiers
if config.TrustedRoot != "" {
customVerifiers, err := createCustomVerifiers(config.TrustedRoot, config.NoPublicGood)
if err != nil {
return nil, err
}
liveVerifier.Custom = customVerifiers
return liveVerifier, nil
}
customTrustRoots, err := os.ReadFile(v.TrustedRoot)
if !config.NoPublicGood {
publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir)
if err != nil {
return nil, err
}
liveVerifier.PublicGood = publicGoodVerifier
}
github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir)
if err != nil {
return nil, fmt.Errorf("unable to read file %s: %v", v.TrustedRoot, err)
return nil, err
}
liveVerifier.GitHub = github
return liveVerifier, nil
}
func createCustomVerifiers(trustedRoot string, noPublicGood bool) (map[string]*verify.SignedEntityVerifier, error) {
customTrustRoots, err := os.ReadFile(trustedRoot)
if err != nil {
return nil, fmt.Errorf("unable to read file %s: %v", trustedRoot, err)
}
verifiers := make(map[string]*verify.SignedEntityVerifier)
reader := bufio.NewReader(bytes.NewReader(customTrustRoots))
var line []byte
var readError error
@ -130,10 +116,11 @@ func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEnti
return nil, err
}
// if the custom trusted root issuer is not set or doesn't match the given issuer, skip it
if len(lowestCert.Issuer.Organization) == 0 || lowestCert.Issuer.Organization[0] != issuer {
// if the custom trusted root issuer is not set, skip it
if len(lowestCert.Issuer.Organization) == 0 {
continue
}
issuer := lowestCert.Issuer.Organization[0]
// Determine what policy to use with this trusted root.
//
@ -141,21 +128,88 @@ func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEnti
// issuer. We *must* use the trusted root provided.
switch issuer {
case PublicGoodIssuerOrg:
if v.NoPublicGood {
if noPublicGood {
return nil, fmt.Errorf("detected public good instance but requested verification without public good instance")
}
return newPublicGoodVerifierWithTrustedRoot(trustedRoot)
if _, ok := verifiers[PublicGoodIssuerOrg]; ok {
// we have already created a public good verifier with this custom trusted root
// so we skip it
continue
}
publicGood, err := newPublicGoodVerifierWithTrustedRoot(trustedRoot)
if err != nil {
return nil, err
}
verifiers[PublicGoodIssuerOrg] = publicGood
case GitHubIssuerOrg:
return newGitHubVerifierWithTrustedRoot(trustedRoot)
if _, ok := verifiers[GitHubIssuerOrg]; ok {
// we have already created a github verifier with this custom trusted root
// so we skip it
continue
}
github, err := newGitHubVerifierWithTrustedRoot(trustedRoot)
if err != nil {
return nil, err
}
verifiers[GitHubIssuerOrg] = github
default:
if _, ok := verifiers[issuer]; ok {
// we have already created a custom verifier with this custom trusted root
// so we skip it
continue
}
// Make best guess at reasonable policy
return newCustomVerifier(trustedRoot)
custom, err := newCustomVerifier(trustedRoot)
if err != nil {
return nil, err
}
verifiers[issuer] = custom
}
}
line, readError = reader.ReadBytes('\n')
}
return verifiers, nil
}
return nil, fmt.Errorf("unable to use provided trusted roots")
func getBundleIssuer(b *bundle.Bundle) (string, error) {
if !b.MinVersion("0.2") {
return "", fmt.Errorf("unsupported bundle version: %s", b.MediaType)
}
verifyContent, err := b.VerificationContent()
if err != nil {
return "", fmt.Errorf("failed to get bundle verification content: %v", err)
}
leafCert := verifyContent.Certificate()
if leafCert == nil {
return "", fmt.Errorf("leaf cert not found")
}
if len(leafCert.Issuer.Organization) != 1 {
return "", fmt.Errorf("expected the leaf certificate issuer to only have one organization")
}
return leafCert.Issuer.Organization[0], nil
}
func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) {
// if no custom trusted root is set, return either the Public Good or GitHub verifier
// If the chosen verifier has not yet been created, create it as a LiveSigstoreVerifier field for use in future calls
if v.Custom != nil {
custom, ok := v.Custom[issuer]
if !ok {
return nil, fmt.Errorf("no custom verifier found for issuer \"%s\"", issuer)
}
return custom, nil
}
switch issuer {
case PublicGoodIssuerOrg:
if v.NoPublicGood {
return nil, fmt.Errorf("detected public good instance but requested verification without public good instance")
}
return v.PublicGood, nil
case GitHubIssuerOrg:
return v.GitHub, nil
default:
return nil, fmt.Errorf("leaf certificate issuer is not recognized")
}
}
func getLowestCertInChain(ca *root.FulcioCertificateAuthority) (*x509.Certificate, error) {
@ -177,7 +231,7 @@ func (v *LiveSigstoreVerifier) verify(attestation *api.Attestation, policy verif
// determine which verifier should attempt verification against the bundle
verifier, err := v.chooseVerifier(issuer)
if err != nil {
return nil, fmt.Errorf("failed to find recognized issuer from bundle content: %v", err)
return nil, fmt.Errorf("failed to choose verifier based on provided bundle issuer: %v", err)
}
v.Logger.VerbosePrintf("Attempting verification against issuer \"%s\"\n", issuer)

View file

@ -50,10 +50,11 @@ func TestLiveSigstoreVerifier(t *testing.T) {
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})
require.NoError(t, err)
results, err := verifier.Verify(tc.attestations, publicGoodPolicy(t))
@ -69,10 +70,11 @@ func TestLiveSigstoreVerifier(t *testing.T) {
}
t.Run("with 2/3 verified attestations", func(t *testing.T) {
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})
require.NoError(t, err)
invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json")
attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
@ -86,10 +88,11 @@ func TestLiveSigstoreVerifier(t *testing.T) {
})
t.Run("fail with 0/2 verified attestations", func(t *testing.T) {
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})
require.NoError(t, err)
invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json")
attestations := getAttestationsFor(t, "../test/data/sigstoreBundle-invalid-signature.json")
@ -110,10 +113,11 @@ func TestLiveSigstoreVerifier(t *testing.T) {
attestations := getAttestationsFor(t, "../test/data/github_provenance_demo-0.0.12-py3-none-any-bundle.jsonl")
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})
require.NoError(t, err)
results, err := verifier.Verify(attestations, githubPolicy)
require.Len(t, results, 1)
@ -123,11 +127,12 @@ func TestLiveSigstoreVerifier(t *testing.T) {
t.Run("with custom trusted root", func(t *testing.T) {
attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{
Logger: io.NewTestHandler(),
TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"),
TUFMetadataDir: o.Some(t.TempDir()),
})
require.NoError(t, err)
results, err := verifier.Verify(attestations, publicGoodPolicy(t))
require.Len(t, results, 2)

View file

@ -25,10 +25,11 @@ func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation {
}
func TestVerifyAttestations(t *testing.T) {
sgVerifier := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{
sgVerifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{
Logger: io.NewTestHandler(),
TUFMetadataDir: o.Some(t.TempDir()),
})
require.NoError(t, err)
certSummary := certificate.Summary{}
certSummary.SourceRepositoryOwnerURI = "https://github.com/sigstore"

View file

@ -211,7 +211,11 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
return runF(opts)
}
opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config)
sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(config)
if err != nil {
return fmt.Errorf("error creating Sigstore verifier: %w", err)
}
opts.SigstoreVerifier = sigstoreVerifier
opts.Config = f.Config
if err := runVerify(opts); err != nil {

View file

@ -33,6 +33,8 @@ func TestVerifyIntegration(t *testing.T) {
host, _ := auth.DefaultHost()
sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
require.NoError(t, err)
publicGoodOpts := Options{
APIClient: api.NewLiveClient(hc, host, logger),
ArtifactPath: artifactPath,
@ -44,7 +46,7 @@ func TestVerifyIntegration(t *testing.T) {
Owner: "sigstore",
PredicateType: verification.SLSAPredicateV1,
SANRegex: "^https://github.com/sigstore/",
SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
SigstoreVerifier: sigstoreVerifier,
}
t.Run("with valid owner", func(t *testing.T) {
@ -106,6 +108,8 @@ func TestVerifyIntegration(t *testing.T) {
})
t.Run("with bundle from OCI registry", func(t *testing.T) {
sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
require.NoError(t, err)
opts := Options{
APIClient: api.NewLiveClient(hc, host, logger),
ArtifactPath: "oci://ghcr.io/github/artifact-attestations-helm-charts/policy-controller:v0.10.0-github9",
@ -117,10 +121,10 @@ func TestVerifyIntegration(t *testing.T) {
Owner: "github",
PredicateType: verification.SLSAPredicateV1,
SANRegex: "^https://github.com/github/",
SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
SigstoreVerifier: sigstoreVerifier,
}
err := runVerify(&opts)
err = runVerify(&opts)
require.NoError(t, err)
})
}
@ -145,6 +149,8 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) {
host, _ := auth.DefaultHost()
sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
require.NoError(t, err)
baseOpts := Options{
APIClient: api.NewLiveClient(hc, host, logger),
ArtifactPath: artifactPath,
@ -154,7 +160,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) {
OCIClient: oci.NewLiveClient(),
OIDCIssuer: "https://token.actions.githubusercontent.com/hammer-time",
PredicateType: verification.SLSAPredicateV1,
SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
SigstoreVerifier: sigstoreVerifier,
}
t.Run("with owner and valid workflow SAN", func(t *testing.T) {
@ -216,6 +222,8 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) {
host, _ := auth.DefaultHost()
sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
require.NoError(t, err)
baseOpts := Options{
APIClient: api.NewLiveClient(hc, host, logger),
ArtifactPath: artifactPath,
@ -225,7 +233,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) {
OCIClient: oci.NewLiveClient(),
OIDCIssuer: verification.GitHubOIDCIssuer,
PredicateType: verification.SLSAPredicateV1,
SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
SigstoreVerifier: sigstoreVerifier,
}
t.Run("with owner and valid reusable workflow SAN", func(t *testing.T) {
@ -306,6 +314,8 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) {
host, _ := auth.DefaultHost()
sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig)
require.NoError(t, err)
baseOpts := Options{
APIClient: api.NewLiveClient(hc, host, logger),
ArtifactPath: artifactPath,
@ -318,7 +328,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) {
Owner: "malancas",
PredicateType: verification.SLSAPredicateV1,
Repo: "malancas/attest-demo",
SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
SigstoreVerifier: sigstoreVerifier,
}
type testcase struct {

View file

@ -4,6 +4,7 @@ import (
"bytes"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/pkg/cmdutil"
@ -91,14 +92,16 @@ func Test_listRun(t *testing.T) {
return cfg
}(),
input: &ListOptions{Hostname: "HOST"},
stdout: `git_protocol=ssh
editor=/usr/bin/vim
prompt=disabled
prefer_editor_prompt=enabled
pager=less
http_unix_socket=
browser=brave
`,
stdout: heredoc.Doc(`
git_protocol=ssh
editor=/usr/bin/vim
prompt=disabled
prefer_editor_prompt=enabled
pager=less
http_unix_socket=
browser=brave
color_labels=disabled
`),
},
}

View file

@ -163,7 +163,8 @@ func (e *Extension) IsPinned() bool {
isPinned = manifest.IsPinned
}
case GitKind:
pinPath := filepath.Join(e.Path(), fmt.Sprintf(".pin-%s", e.CurrentVersion()))
extDir := filepath.Dir(e.path)
pinPath := filepath.Join(extDir, fmt.Sprintf(".pin-%s", e.CurrentVersion()))
if _, err := os.Stat(pinPath); err == nil {
isPinned = true
} else {

View file

@ -102,3 +102,84 @@ func TestOwnerCached(t *testing.T) {
assert.Equal(t, "cli", e.Owner())
}
func TestIsPinnedBinaryExtensionUnpinned(t *testing.T) {
tempDir := t.TempDir()
extName := "gh-bin-ext"
extDir := filepath.Join(tempDir, "extensions", extName)
extPath := filepath.Join(extDir, extName)
bm := binManifest{
Name: "gh-bin-ext",
}
assert.NoError(t, stubBinaryExtension(extDir, bm))
e := &Extension{
kind: BinaryKind,
path: extPath,
}
assert.False(t, e.IsPinned())
}
func TestIsPinnedBinaryExtensionPinned(t *testing.T) {
tempDir := t.TempDir()
extName := "gh-bin-ext"
extDir := filepath.Join(tempDir, "extensions", extName)
extPath := filepath.Join(extDir, extName)
bm := binManifest{
Name: "gh-bin-ext",
IsPinned: true,
}
assert.NoError(t, stubBinaryExtension(extDir, bm))
e := &Extension{
kind: BinaryKind,
path: extPath,
}
assert.True(t, e.IsPinned())
}
func TestIsPinnedGitExtensionUnpinned(t *testing.T) {
tempDir := t.TempDir()
extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local")
assert.NoError(t, stubExtension(extPath))
gc := &mockGitClient{}
gc.On("CommandOutput", []string{"rev-parse", "HEAD"}).Return("abcd1234", nil)
e := &Extension{
kind: GitKind,
gitClient: gc,
path: extPath,
}
assert.False(t, e.IsPinned())
gc.AssertExpectations(t)
}
func TestIsPinnedGitExtensionPinned(t *testing.T) {
tempDir := t.TempDir()
extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local")
assert.NoError(t, stubPinnedExtension(extPath, "abcd1234"))
gc := &mockGitClient{}
gc.On("CommandOutput", []string{"rev-parse", "HEAD"}).Return("abcd1234", nil)
e := &Extension{
kind: GitKind,
gitClient: gc,
path: extPath,
}
assert.True(t, e.IsPinned())
gc.AssertExpectations(t)
}
func TestIsPinnedLocalExtension(t *testing.T) {
tempDir := t.TempDir()
extPath := filepath.Join(tempDir, "extensions", "gh-local", "gh-local")
assert.NoError(t, stubLocalExtension(tempDir, extPath))
e := &Extension{
kind: LocalKind,
path: extPath,
}
assert.False(t, e.IsPinned())
}

View file

@ -6,6 +6,7 @@ import (
"net/http"
"os"
"regexp"
"slices"
"time"
"github.com/cli/cli/v2/api"
@ -283,6 +284,12 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams {
io.SetNeverPrompt(true)
}
ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED")
falseyValues := []string{"false", "0", "no", ""}
if ghSpinnerDisabledIsSet && !slices.Contains(falseyValues, ghSpinnerDisabledValue) {
io.SetSpinnerDisabled(true)
}
// Pager precedence
// 1. GH_PAGER
// 2. pager from config
@ -293,6 +300,17 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams {
io.SetPager(pager.Value)
}
if ghColorLabels, ghColorLabelsExists := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabelsExists {
switch ghColorLabels {
case "", "0", "false", "no":
io.SetColorLabels(false)
default:
io.SetColorLabels(true)
}
} else if prompt := cfg.ColorLabels(""); prompt.Value == "enabled" {
io.SetColorLabels(true)
}
io.SetAccessibleColorsEnabled(xcolor.IsAccessibleColorsEnabled())
return io

View file

@ -432,6 +432,132 @@ func Test_ioStreams_prompt(t *testing.T) {
}
}
func Test_ioStreams_spinnerDisabled(t *testing.T) {
tests := []struct {
name string
spinnerDisabled bool
env map[string]string
}{
{
name: "default config",
spinnerDisabled: false,
},
{
name: "spinner disabled via GH_SPINNER_DISABLED env var = 0",
env: map[string]string{"GH_SPINNER_DISABLED": "0"},
spinnerDisabled: false,
},
{
name: "spinner disabled via GH_SPINNER_DISABLED env var = false",
env: map[string]string{"GH_SPINNER_DISABLED": "false"},
spinnerDisabled: false,
},
{
name: "spinner disabled via GH_SPINNER_DISABLED env var = no",
env: map[string]string{"GH_SPINNER_DISABLED": "no"},
spinnerDisabled: false,
},
{
name: "spinner enabled via GH_SPINNER_DISABLED env var = 1",
env: map[string]string{"GH_SPINNER_DISABLED": "1"},
spinnerDisabled: true,
},
{
name: "spinner enabled via GH_SPINNER_DISABLED env var = true",
env: map[string]string{"GH_SPINNER_DISABLED": "true"},
spinnerDisabled: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for k, v := range tt.env {
t.Setenv(k, v)
}
f := New("1")
io := ioStreams(f)
assert.Equal(t, tt.spinnerDisabled, io.GetSpinnerDisabled())
})
}
}
func Test_ioStreams_colorLabels(t *testing.T) {
tests := []struct {
name string
config gh.Config
colorLabelsEnabled bool
env map[string]string
}{
{
name: "default config",
colorLabelsEnabled: false,
},
{
name: "config with colorLabels enabled",
config: enableColorLabelsConfig(),
colorLabelsEnabled: true,
},
{
name: "config with colorLabels disabled",
config: disableColorLabelsConfig(),
colorLabelsEnabled: false,
},
{
name: "colorLabels enabled via `1` in GH_COLOR_LABELS env var",
env: map[string]string{"GH_COLOR_LABELS": "1"},
colorLabelsEnabled: true,
},
{
name: "colorLabels enabled via `true` in GH_COLOR_LABELS env var",
env: map[string]string{"GH_COLOR_LABELS": "true"},
colorLabelsEnabled: true,
},
{
name: "colorLabels enabled via `yes` in GH_COLOR_LABELS env var",
env: map[string]string{"GH_COLOR_LABELS": "yes"},
colorLabelsEnabled: true,
},
{
name: "colorLabels disable via empty string in GH_COLOR_LABELS env var",
env: map[string]string{"GH_COLOR_LABELS": ""},
colorLabelsEnabled: false,
},
{
name: "colorLabels disabled via `0` in GH_COLOR_LABELS env var",
env: map[string]string{"GH_COLOR_LABELS": "0"},
colorLabelsEnabled: false,
},
{
name: "colorLabels disabled via `false` in GH_COLOR_LABELS env var",
env: map[string]string{"GH_COLOR_LABELS": "false"},
colorLabelsEnabled: false,
},
{
name: "colorLabels disabled via `no` in GH_COLOR_LABELS env var",
env: map[string]string{"GH_COLOR_LABELS": "no"},
colorLabelsEnabled: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != nil {
for k, v := range tt.env {
t.Setenv(k, v)
}
}
f := New("1")
f.Config = func() (gh.Config, error) {
if tt.config == nil {
return config.NewBlankConfig(), nil
} else {
return tt.config, nil
}
}
io := ioStreams(f)
assert.Equal(t, tt.colorLabelsEnabled, io.ColorLabels())
})
}
}
func TestSSOURL(t *testing.T) {
tests := []struct {
name string
@ -537,3 +663,11 @@ func pagerConfig() gh.Config {
func disablePromptConfig() gh.Config {
return config.NewFromString("prompt: disabled")
}
func disableColorLabelsConfig() gh.Config {
return config.NewFromString("color_labels: disabled")
}
func enableColorLabelsConfig() gh.Config {
return config.NewFromString("color_labels: enabled")
}

View file

@ -138,7 +138,7 @@ func createRun(opts *CreateOptions) error {
processMessage = fmt.Sprintf("Creating gist %s", gistName)
}
}
fmt.Fprintf(errOut, "%s %s\n", cs.Gray("-"), processMessage)
fmt.Fprintf(errOut, "%s %s\n", cs.Muted("-"), processMessage)
httpClient, err := opts.HttpClient()
if err != nil {

View file

@ -654,50 +654,57 @@ func Test_highlightMatch(t *testing.T) {
tests := []struct {
name string
input string
color bool
cs *iostreams.ColorScheme
want string
}{
{
name: "single match",
input: "Octo",
cs: &iostreams.ColorScheme{},
want: "Octo",
},
{
name: "single match (color)",
input: "Octo",
color: true,
want: "\x1b[0;30;43mOcto\x1b[0m",
cs: &iostreams.ColorScheme{
Enabled: true,
},
want: "\x1b[0;30;43mOcto\x1b[0m",
},
{
name: "single match with extra",
input: "Hello, Octocat!",
cs: &iostreams.ColorScheme{},
want: "Hello, Octocat!",
},
{
name: "single match with extra (color)",
input: "Hello, Octocat!",
color: true,
want: "\x1b[0;34mHello, \x1b[0m\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat!\x1b[0m",
cs: &iostreams.ColorScheme{
Enabled: true,
},
want: "\x1b[0;34mHello, \x1b[0m\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat!\x1b[0m",
},
{
name: "multiple matches",
input: "Octocat/octo",
cs: &iostreams.ColorScheme{},
want: "Octocat/octo",
},
{
name: "multiple matches (color)",
input: "Octocat/octo",
color: true,
want: "\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat/\x1b[0m\x1b[0;30;43mocto\x1b[0m",
cs: &iostreams.ColorScheme{
Enabled: true,
},
want: "\x1b[0;30;43mOcto\x1b[0m\x1b[0;34mcat/\x1b[0m\x1b[0;30;43mocto\x1b[0m",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs := iostreams.NewColorScheme(tt.color, false, false, false, iostreams.NoTheme)
matched := false
got, err := highlightMatch(tt.input, regex, &matched, cs.Blue, cs.Highlight)
got, err := highlightMatch(tt.input, regex, &matched, tt.cs.Blue, tt.cs.Highlight)
assert.NoError(t, err)
assert.True(t, matched)
assert.Equal(t, tt.want, got)

View file

@ -230,7 +230,7 @@ func PromptGists(prompter prompter.Prompter, client *http.Client, host string, c
for i, gist := range gists {
gistTime := text.FuzzyAgo(time.Now(), gist.UpdatedAt)
// TODO: support dynamic maxWidth
opts[i] = fmt.Sprintf("%s %s %s", cs.Bold(gist.Filename()), gist.TruncDescription(), cs.Gray(gistTime))
opts[i] = fmt.Sprintf("%s %s %s", cs.Bold(gist.Filename()), gist.TruncDescription(), cs.Muted(gistTime))
}
result, err := prompter.Select("Select a gist", "", opts)

View file

@ -140,7 +140,7 @@ func viewRun(opts *ViewOptions) error {
if len(gist.Files) == 1 || opts.Filename != "" {
return fmt.Errorf("error: file is binary")
}
_, err = fmt.Fprintln(opts.IO.Out, cs.Gray("(skipping rendering binary content)"))
_, err = fmt.Fprintln(opts.IO.Out, cs.Muted("(skipping rendering binary content)"))
return nil
}
@ -197,7 +197,7 @@ func viewRun(opts *ViewOptions) error {
for i, fn := range filenames {
if showFilenames {
fmt.Fprintf(opts.IO.Out, "%s\n\n", cs.Gray(fn))
fmt.Fprintf(opts.IO.Out, "%s\n\n", cs.Muted(fn))
}
if err := render(gist.Files[fn]); err != nil {
return err

View file

@ -38,13 +38,13 @@ func PrintIssues(io *iostreams.IOStreams, now time.Time, prefix string, totalCou
}
table.AddField(text.RemoveExcessiveWhitespace(issue.Title))
table.AddField(issueLabelList(&issue, cs, isTTY))
table.AddTimeField(now, issue.UpdatedAt, cs.Gray)
table.AddTimeField(now, issue.UpdatedAt, cs.Muted)
table.EndRow()
}
_ = table.Render()
remaining := totalCount - len(issues)
if remaining > 0 {
fmt.Fprintf(io.Out, cs.Gray("%sAnd %d more\n"), prefix, remaining)
fmt.Fprintf(io.Out, cs.Muted("%sAnd %d more\n"), prefix, remaining)
}
}
@ -56,7 +56,7 @@ func issueLabelList(issue *api.Issue, cs *iostreams.ColorScheme, colorize bool)
labelNames := make([]string, 0, len(issue.Labels.Nodes))
for _, label := range issue.Labels.Nodes {
if colorize {
labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name))
labelNames = append(labelNames, cs.Label(label.Color, label.Name))
} else {
labelNames = append(labelNames, label.Name)
}

View file

@ -228,7 +228,7 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue
var md string
var err error
if issue.Body == "" {
md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided"))
md = fmt.Sprintf("\n %s\n\n", cs.Muted("No description provided"))
} else {
md, err = markdown.Render(issue.Body,
markdown.WithTheme(opts.IO.TerminalTheme()),
@ -250,7 +250,7 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue
}
// Footer
fmt.Fprintf(out, cs.Gray("View this issue on GitHub: %s\n"), issue.URL)
fmt.Fprintf(out, cs.Muted("View this issue on GitHub: %s\n"), issue.URL)
return nil
}
@ -317,7 +317,7 @@ func issueLabelList(issue *api.Issue, cs *iostreams.ColorScheme) string {
if cs == nil {
labelNames[i] = label.Name
} else {
labelNames[i] = cs.HexToRGB(label.Color, label.Name)
labelNames[i] = cs.Label(label.Color, label.Name)
}
}

View file

@ -137,7 +137,12 @@ func printLabels(io *iostreams.IOStreams, labels []label) error {
table := tableprinter.New(io, tableprinter.WithHeader("NAME", "DESCRIPTION", "COLOR"))
for _, label := range labels {
table.AddField(label.Name, tableprinter.WithColor(cs.ColorFromRGB(label.Color)))
// Colorize the label using tableprinter's WithColor function for it to handle non-TTY situations
labelColor := tableprinter.WithColor(func(s string) string {
return cs.Label(label.Color, s)
})
table.AddField(label.Name, labelColor)
table.AddField(label.Description)
table.AddField("#" + label.Color)

View file

@ -30,7 +30,7 @@ func addRow(tp *tableprinter.TablePrinter, io *iostreams.IOStreams, o check) {
markColor = cs.Yellow
case "skipping", "cancel":
mark = "-"
markColor = cs.Gray
markColor = cs.Muted
}
if io.IsStdoutTTY() {

View file

@ -25,6 +25,7 @@ import (
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/markdown"
o "github.com/cli/cli/v2/pkg/option"
"github.com/spf13/cobra"
)
@ -72,18 +73,107 @@ type CreateOptions struct {
DryRun bool
}
// creationRefs is an interface that provides the necessary information for creating a pull request in the API.
// Upcasting to concrete implementations can provide further context on other operations (forking and pushing).
type creationRefs interface {
// QualifiedHeadRef returns a stringified form of the head ref, varying depending
// on whether the head ref is in the same repository as the base ref. If they are
// the same repository, we return the branch name only. If they are different repositories,
// we return the owner and branch name in the form <owner>:<branch>.
QualifiedHeadRef() string
// UnqualifiedHeadRef returns a head ref in the form of the branch name only.
UnqualifiedHeadRef() string
//BaseRef returns the base branch name.
BaseRef() string
// While the only thing really required from an api.Repository is the repository ID, changing that
// would require changing the API function signatures, and the refactor that introduced this refs
// type is already large enough.
BaseRepo() *api.Repository
}
type baseRefs struct {
baseRepo *api.Repository
baseBranchName string
}
func (r baseRefs) BaseRef() string {
return r.baseBranchName
}
func (r baseRefs) BaseRepo() *api.Repository {
return r.baseRepo
}
// skipPushRefs indicate to handlePush that no pushing is required.
type skipPushRefs struct {
baseRefs
qualifiedHeadRef shared.QualifiedHeadRef
}
func (r skipPushRefs) QualifiedHeadRef() string {
return r.qualifiedHeadRef.String()
}
func (r skipPushRefs) UnqualifiedHeadRef() string {
return r.qualifiedHeadRef.BranchName()
}
// pushableRefs indicate to handlePush that pushing is required,
// and provide further information (HeadRepo) on where that push
// should go.
type pushableRefs struct {
baseRefs
headRepo ghrepo.Interface
headBranchName string
}
func (r pushableRefs) QualifiedHeadRef() string {
if ghrepo.IsSame(r.headRepo, r.baseRepo) {
return r.headBranchName
}
return fmt.Sprintf("%s:%s", r.headRepo.RepoOwner(), r.headBranchName)
}
func (r pushableRefs) UnqualifiedHeadRef() string {
return r.headBranchName
}
func (r pushableRefs) HeadRepo() ghrepo.Interface {
return r.headRepo
}
// forkableRefs indicate to handlePush that forking is required before
// pushing. The expectation is that after forking, this is converted to
// pushableRefs. We could go very OOP and have a Fork method on this
// struct that returns a pushableRefs but then we'd need to embed an API client
// and it just seems nice that it is a simple bag of data.
type forkableRefs struct {
baseRefs
qualifiedHeadRef shared.QualifiedHeadRef
}
func (r forkableRefs) QualifiedHeadRef() string {
return r.qualifiedHeadRef.String()
}
func (r forkableRefs) UnqualifiedHeadRef() string {
return r.qualifiedHeadRef.BranchName()
}
// CreateContext stores contextual data about the creation process and is for building up enough
// data to create a pull request.
type CreateContext struct {
// This struct stores contextual data about the creation process and is for building up enough
// data to create a pull request
RepoContext *ghContext.ResolvedRemotes
BaseRepo *api.Repository
HeadRepo ghrepo.Interface
ResolvedRemotes *ghContext.ResolvedRemotes
PRRefs creationRefs
// BaseTrackingBranch is perhaps a slightly leaky abstraction in the presence
// of PRRefs, but a huge amount of refactoring was done to introduce that struct,
// and this is a small price to pay for the convenience of not having to do a lot
// more design.
BaseTrackingBranch string
BaseBranch string
HeadBranch string
HeadBranchLabel string
HeadRemote *ghContext.Remote
IsPushEnabled bool
Client *api.Client
GitClient *git.Client
}
@ -113,6 +203,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
to push the branch and offer an option to fork the base repository. Use %[1]s--head%[1]s to
explicitly skip any forking or pushing behavior.
%[1]s--head%[1]s supports %[1]s<user>:<branch>%[1]s syntax to select a head repo owned by %[1]s<user>%[1]s.
Using an organization as the %[1]s<user>%[1]s is currently not supported.
For more information, see <https://github.com/cli/cli/issues/10093>
A prompt will also ask for the title and the body of the pull request. Use %[1]s--title%[1]s and
%[1]s--body%[1]s to skip this, or use %[1]s--fill%[1]s to autofill these values from git commits.
It's important to notice that if the %[1]s--title%[1]s and/or %[1]s--body%[1]s are also provided
@ -310,8 +404,8 @@ func createRun(opts *CreateOptions) error {
}
existingPR, _, err := opts.Finder.Find(shared.FindOptions{
Selector: ctx.HeadBranchLabel,
BaseBranch: ctx.BaseBranch,
Selector: ctx.PRRefs.QualifiedHeadRef(),
BaseBranch: ctx.PRRefs.BaseRef(),
States: []string{"OPEN"},
Fields: []string{"url"},
})
@ -321,7 +415,7 @@ func createRun(opts *CreateOptions) error {
}
if err == nil {
return fmt.Errorf("a pull request for branch %q into branch %q already exists:\n%s",
ctx.HeadBranchLabel, ctx.BaseBranch, existingPR.URL)
ctx.PRRefs.QualifiedHeadRef(), ctx.PRRefs.BaseRef(), existingPR.URL)
}
message := "\nCreating pull request for %s into %s in %s\n\n"
@ -336,9 +430,9 @@ func createRun(opts *CreateOptions) error {
if opts.IO.CanPrompt() {
fmt.Fprintf(opts.IO.ErrOut, message,
cs.Cyan(ctx.HeadBranchLabel),
cs.Cyan(ctx.BaseBranch),
ghrepo.FullName(ctx.BaseRepo))
cs.Cyan(ctx.PRRefs.QualifiedHeadRef()),
cs.Cyan(ctx.PRRefs.BaseRef()),
ghrepo.FullName(ctx.PRRefs.BaseRepo()))
}
if !opts.EditorMode && (opts.FillVerbose || opts.Autofill || opts.FillFirst || (opts.TitleProvided && opts.BodyProvided)) {
@ -361,7 +455,7 @@ func createRun(opts *CreateOptions) error {
action = shared.SubmitDraftAction
}
tpl := shared.NewTemplateManager(client.HTTP(), ctx.BaseRepo, opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true)
tpl := shared.NewTemplateManager(client.HTTP(), ctx.PRRefs.BaseRepo(), opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true)
if opts.EditorMode {
if opts.Template != "" {
@ -429,7 +523,7 @@ func createRun(opts *CreateOptions) error {
}
allowPreview := !state.HasMetadata() && shared.ValidURL(openURL) && !opts.DryRun
allowMetadata := ctx.BaseRepo.ViewerCanTriage()
allowMetadata := ctx.PRRefs.BaseRepo().ViewerCanTriage()
action, err = shared.ConfirmPRSubmission(opts.Prompter, allowPreview, allowMetadata, state.Draft)
if err != nil {
return fmt.Errorf("unable to confirm: %w", err)
@ -439,10 +533,10 @@ func createRun(opts *CreateOptions) error {
fetcher := &shared.MetadataFetcher{
IO: opts.IO,
APIClient: client,
Repo: ctx.BaseRepo,
Repo: ctx.PRRefs.BaseRepo(),
State: state,
}
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.BaseRepo, fetcher, state)
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state)
if err != nil {
return err
}
@ -485,11 +579,7 @@ func createRun(opts *CreateOptions) error {
var regexPattern = regexp.MustCompile(`(?m)^`)
func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, useFirstCommit bool, addBody bool) error {
baseRef := ctx.BaseTrackingBranch
headRef := ctx.HeadBranch
gitClient := ctx.GitClient
commits, err := gitClient.Commits(context.Background(), baseRef, headRef)
commits, err := ctx.GitClient.Commits(context.Background(), ctx.BaseTrackingBranch, ctx.PRRefs.UnqualifiedHeadRef())
if err != nil {
return err
}
@ -498,7 +588,7 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, u
state.Title = commits[len(commits)-1].Title
state.Body = commits[len(commits)-1].Body
} else {
state.Title = humanize(headRef)
state.Title = humanize(ctx.PRRefs.UnqualifiedHeadRef())
var body strings.Builder
for i := len(commits) - 1; i >= 0; i-- {
fmt.Fprintf(&body, "- **%s**\n", commits[i].Title)
@ -518,90 +608,13 @@ 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
branchName string
}
func (r trackingRef) String() string {
return "refs/remotes/" + r.remoteName + "/" + r.branchName
}
func mustParseTrackingRef(text string) trackingRef {
parts := strings.SplitN(string(text), "/", 4)
// The only place this is called is tryDetermineTrackingRef, where we are reconstructing
// the same tracking ref we passed in. If it doesn't match the expected format, this is a
// programmer error we want to know about, so it's ok to panic.
if len(parts) != 4 {
panic(fmt.Errorf("tracking ref should have four parts: %s", text))
}
if parts[0] != "refs" || parts[1] != "remotes" {
panic(fmt.Errorf("tracking ref should start with refs/remotes/: %s", text))
}
return trackingRef{
remoteName: parts[2],
branchName: parts[3],
}
}
// tryDetermineTrackingRef is intended to try and find a remote branch on the same commit as the currently checked out
// HEAD, i.e. the local branch. If there are multiple branches that might match, the first remote is chosen, which in
// practice is determined by the sorting algorithm applied much earlier in the process, roughly "upstream", "github", "origin",
// and then everything else unstably sorted.
func tryDetermineTrackingRef(gitClient *git.Client, remotes ghContext.Remotes, localBranchName string, headBranchConfig git.BranchConfig) (trackingRef, bool) {
// To try and determine the tracking ref for a local branch, we first construct a collection of refs
// that might be tracking, given the current branch's config, and the list of known remotes.
refsForLookup := []string{"HEAD"}
if headBranchConfig.RemoteName != "" && headBranchConfig.MergeRef != "" {
tr := trackingRef{
remoteName: headBranchConfig.RemoteName,
branchName: strings.TrimPrefix(headBranchConfig.MergeRef, "refs/heads/"),
}
refsForLookup = append(refsForLookup, tr.String())
}
for _, remote := range remotes {
tr := trackingRef{
remoteName: remote.Name,
branchName: localBranchName,
}
refsForLookup = append(refsForLookup, tr.String())
}
// Then we ask git for details about these refs, for example, refs/remotes/origin/trunk might return a hash
// for the remote tracking branch, trunk, for the remote, origin. If there is no ref, the git client returns
// no ref information.
//
// We also first check for the HEAD ref, so that we have the hash of the currently checked out commit.
resolvedRefs, _ := gitClient.ShowRefs(context.Background(), refsForLookup)
// If there is more than one resolved ref, that means that at least one ref was found in addition to the HEAD.
if len(resolvedRefs) > 1 {
headRef := resolvedRefs[0]
for _, r := range resolvedRefs[1:] {
// If the hash of the remote ref doesn't match the hash of HEAD then the remote branch is not in the same
// state, so it can't be used.
if r.Hash != headRef.Hash {
continue
}
// Otherwise we can parse the returned ref into a tracking ref and return that
return mustParseTrackingRef(r.Name), true
}
}
return trackingRef{}, false
}
func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadataState, error) {
var milestoneTitles []string
if opts.Milestone != "" {
milestoneTitles = []string{opts.Milestone}
}
meReplacer := shared.NewMeReplacer(ctx.Client, ctx.BaseRepo.RepoHost())
meReplacer := shared.NewMeReplacer(ctx.Client, ctx.PRRefs.BaseRepo().RepoHost())
assignees, err := meReplacer.ReplaceSlice(opts.Assignees)
if err != nil {
return nil, err
@ -638,13 +651,14 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
if err != nil {
return nil, err
}
repoContext, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride)
resolvedRemotes, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride)
if err != nil {
return nil, err
}
var baseRepo *api.Repository
if br, err := repoContext.BaseRepo(opts.IO); err == nil {
if br, err := resolvedRemotes.BaseRepo(opts.IO); err == nil {
if r, ok := br.(*api.Repository); ok {
baseRepo = r
} else {
@ -659,137 +673,284 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
return nil, err
}
isPushEnabled := false
headBranch := opts.HeadBranch
headBranchLabel := opts.HeadBranch
if headBranch == "" {
headBranch, err = opts.Branch()
if err != nil {
return nil, fmt.Errorf("could not determine the current branch: %w", err)
// This closure provides an easy way to instantiate a CreateContext with everything other than
// the refs. This probably indicates that CreateContext could do with some rework, but the refactor
// to introduce PRRefs is already large enough.
var newCreateContext = func(refs creationRefs) *CreateContext {
baseTrackingBranch := refs.BaseRef()
// The baseTrackingBranch is used later for a command like:
// `git commit upstream/main feature` in order to create a PR message showing the commits
// between these two refs. I'm not really sure what is expected to happen if we don't have a remote,
// which seems like it would be possible with a command `gh pr create --repo owner/repo-that-is-not-a-remote`.
// In that case, we might just have a mess? In any case, this is what the old code did, so I don't want to change
// it as part of an already large refactor.
baseRemote, _ := resolvedRemotes.RemoteForRepo(baseRepo)
if baseRemote != nil {
baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseTrackingBranch)
}
return &CreateContext{
ResolvedRemotes: resolvedRemotes,
Client: client,
GitClient: opts.GitClient,
PRRefs: refs,
BaseTrackingBranch: baseTrackingBranch,
}
headBranchLabel = headBranch
isPushEnabled = true
} else if idx := strings.IndexRune(headBranch, ':'); idx >= 0 {
headBranch = headBranch[idx+1:]
}
gitClient := opts.GitClient
if ucc, err := gitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 {
// If the user provided a head branch we're going to use that without any interrogation
// of git. The value can take the form of <branch> or <user>:<branch>. In the former case, the
// PR base and head repos are the same. In the latter case we don't know the head repo
// (though we could look it up in the API) but fortunately we don't need to because the API
// will resolve this for us when we create the pull request. This is possible because
// users can only have a single fork in their namespace, and organizations don't work at all with this ref format.
//
// Note that providing the head branch in this way indicates that we shouldn't push the branch,
// and we indicate that via the returned type as well.
if opts.HeadBranch != "" {
qualifiedHeadRef, err := shared.ParseQualifiedHeadRef(opts.HeadBranch)
if err != nil {
return nil, err
}
branchConfig, err := opts.GitClient.ReadBranchConfig(context.Background(), qualifiedHeadRef.BranchName())
if err != nil {
return nil, err
}
baseBranch := opts.BaseBranch
if baseBranch == "" {
baseBranch = branchConfig.MergeBase
}
if baseBranch == "" {
baseBranch = baseRepo.DefaultBranchRef.Name
}
return newCreateContext(skipPushRefs{
qualifiedHeadRef: qualifiedHeadRef,
baseRefs: baseRefs{
baseRepo: baseRepo,
baseBranchName: baseBranch,
},
}), nil
}
if ucc, err := opts.GitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 {
fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", text.Pluralize(ucc, "uncommitted change"))
}
var headRepo ghrepo.Interface
var headRemote *ghContext.Remote
// If the user didn't provide a head branch then we're gettin' real. We're going to interrogate git
// and try to create refs that are pushable.
currentBranch, err := opts.Branch()
if err != nil {
return nil, fmt.Errorf("could not determine the current branch: %w", err)
}
headBranchConfig, err := gitClient.ReadBranchConfig(context.Background(), headBranch)
branchConfig, err := opts.GitClient.ReadBranchConfig(context.Background(), currentBranch)
if err != nil {
return nil, err
}
if isPushEnabled {
// 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 {
headRepo = r
headRemote = r
headBranchLabel = trackingRef.branchName
if !ghrepo.IsSame(baseRepo, headRepo) {
headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), trackingRef.branchName)
}
}
}
}
// otherwise, ask the user for the head repository using info obtained from the API
if headRepo == nil && isPushEnabled && opts.IO.CanPrompt() {
pushableRepos, err := repoContext.HeadRepos()
if err != nil {
return nil, err
}
if len(pushableRepos) == 0 {
pushableRepos, err = api.RepoFindForks(client, baseRepo, 3)
if err != nil {
return nil, err
}
}
currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost())
if err != nil {
return nil, err
}
hasOwnFork := false
var pushOptions []string
for _, r := range pushableRepos {
pushOptions = append(pushOptions, ghrepo.FullName(r))
if r.RepoOwner() == currentLogin {
hasOwnFork = true
}
}
if !hasOwnFork {
pushOptions = append(pushOptions, "Create a fork of "+ghrepo.FullName(baseRepo))
}
pushOptions = append(pushOptions, "Skip pushing the branch")
pushOptions = append(pushOptions, "Cancel")
selectedOption, err := opts.Prompter.Select(fmt.Sprintf("Where should we push the '%s' branch?", headBranch), "", pushOptions)
if err != nil {
return nil, err
}
if selectedOption < len(pushableRepos) {
headRepo = pushableRepos[selectedOption]
if !ghrepo.IsSame(baseRepo, headRepo) {
headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch)
}
} else if pushOptions[selectedOption] == "Skip pushing the branch" {
isPushEnabled = false
} else if pushOptions[selectedOption] == "Cancel" {
return nil, cmdutil.CancelError
} else {
// "Create a fork of ..."
headBranchLabel = fmt.Sprintf("%s:%s", currentLogin, headBranch)
}
}
if headRepo == nil && isPushEnabled && !opts.IO.CanPrompt() {
fmt.Fprintf(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag")
return nil, cmdutil.SilentError
}
baseBranch := opts.BaseBranch
if baseBranch == "" {
baseBranch = headBranchConfig.MergeBase
baseBranch = branchConfig.MergeBase
}
if baseBranch == "" {
baseBranch = baseRepo.DefaultBranchRef.Name
}
if headBranch == baseBranch && headRepo != nil && ghrepo.IsSame(baseRepo, headRepo) {
return nil, fmt.Errorf("must be on a branch named differently than %q", baseBranch)
// First we check with the git information we have to see if we can figure out the default
// head repo and remote branch name.
defaultPRHead, err := shared.TryDetermineDefaultPRHead(
// We requested the branch config already, so let's cache that
shared.CachedBranchConfigGitConfigClient{
CachedBranchConfig: branchConfig,
GitConfigClient: opts.GitClient,
},
shared.NewRemoteToRepoResolver(opts.Remotes),
currentBranch,
)
if err != nil {
return nil, err
}
baseTrackingBranch := baseBranch
if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil {
baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch)
// The baseRefs are always going to be the same from now on. If I could make this immutable I would!
baseRefs := baseRefs{
baseRepo: baseRepo,
baseBranchName: baseBranch,
}
return &CreateContext{
BaseRepo: baseRepo,
HeadRepo: headRepo,
BaseBranch: baseBranch,
BaseTrackingBranch: baseTrackingBranch,
HeadBranch: headBranch,
HeadBranchLabel: headBranchLabel,
HeadRemote: headRemote,
IsPushEnabled: isPushEnabled,
RepoContext: repoContext,
Client: client,
GitClient: gitClient,
}, nil
// If we were able to determine a head repo, then let's check that the remote tracking ref matches the SHA of
// HEAD. If it does, then we don't need to push, otherwise we'll need to ask the user to tell us where to push.
if headRepo, present := defaultPRHead.Repo.Value(); present {
// We may not find a remote because the git branch config may have a URL rather than a remote name.
// Ideally, we would return a sentinel error from RemoteForRepo that we could compare to, but the
// refactor that introduced this code was already large enough.
headRemote, _ := resolvedRemotes.RemoteForRepo(headRepo)
if headRemote != nil {
resolvedRefs, _ := opts.GitClient.ShowRefs(
context.Background(),
[]string{
"HEAD",
fmt.Sprintf("refs/remotes/%s/%s", headRemote.Name, defaultPRHead.BranchName),
},
)
// Two refs returned means we can compare HEAD to the remote tracking branch.
// If we had a matching ref, then we can skip pushing.
refsMatch := len(resolvedRefs) == 2 && resolvedRefs[0].Hash == resolvedRefs[1].Hash
if refsMatch {
qualifiedHeadRef := shared.NewQualifiedHeadRefWithoutOwner(defaultPRHead.BranchName)
if headRepo.RepoOwner() != baseRepo.RepoOwner() {
qualifiedHeadRef = shared.NewQualifiedHeadRef(headRepo.RepoOwner(), defaultPRHead.BranchName)
}
return newCreateContext(skipPushRefs{
qualifiedHeadRef: qualifiedHeadRef,
baseRefs: baseRefs,
}), nil
}
}
}
// If we didn't determine that the git indicated repo had the correct ref, we'll take a look at the other
// remotes and see whether any of them have the same SHA as HEAD. Now, at this point, you might be asking yourself:
// "Why didn't we collect all the SHAs with a single ShowRefs command above, for use in both cases?"
// ...
// That's because the code below has a bug that I've ported from the old code, in order to preserve the existing
// behaviour, and to limit the scope of an already large refactor. The intention of the original code was to loop
// over all the returned refs. However, as it turns out, our implementation of ShowRefs doesn't do that correctly.
// Since it provides the --verify flag, git will return the SHAs for refs up until it hits a ref that doesn't exist,
// at which point it bails out.
//
// Imagine you have a remotes "upstream" and "origin", and you have pushed your branch "feature" to "origin". Since
// the order of remotes is always guaranteed "upstream", "github", "origin", and then everything else unstably sorted,
// we will never get a SHA for origin, as refs/remotes/upstream/feature doesn't exist.
//
// Furthermore, when you really think about it, this code is a bit eager. What happens if you have the same SHA on
// remotes "origin" and "colleague", this will always offer origin. If it were "colleague-a" and "colleague-b", no
// order would be guaranteed between different invocations of pr create, because the order of remotes after "origin"
// is unstable sorted.
//
// All that said, this has been the behaviour for a long, long time, and I do not want to make other behavioural changes
// in what is mostly a refactor.
refsToLookup := []string{"HEAD"}
for _, remote := range remotes {
refsToLookup = append(refsToLookup, fmt.Sprintf("refs/remotes/%s/%s", remote.Name, currentBranch))
}
// Ignoring the error in this case is allowed because we may get refs and an error (see: --verify flag above).
// Ideally there would be a typed error to allow us to distinguish between an execution error and some refs
// not existing. However, this is too much to take on in an already large refactor.
refs, _ := opts.GitClient.ShowRefs(context.Background(), refsToLookup)
if len(refs) > 1 {
headRef := refs[0]
var firstMatchingRef o.Option[git.RemoteTrackingRef]
// Loop over all the refs, trying to find one that matches the SHA of HEAD.
for _, r := range refs[1:] {
if r.Hash == headRef.Hash {
remoteTrackingRef, err := git.ParseRemoteTrackingRef(r.Name)
if err != nil {
return nil, err
}
firstMatchingRef = o.Some(remoteTrackingRef)
break
}
}
// If we found a matching ref, then we don't need to push.
if ref, present := firstMatchingRef.Value(); present {
remote, err := remotes.FindByName(ref.Remote)
if err != nil {
return nil, err
}
qualifiedHeadRef := shared.NewQualifiedHeadRefWithoutOwner(ref.Branch)
if baseRepo.RepoOwner() != remote.RepoOwner() {
qualifiedHeadRef = shared.NewQualifiedHeadRef(remote.RepoOwner(), ref.Branch)
}
return newCreateContext(skipPushRefs{
qualifiedHeadRef: qualifiedHeadRef,
baseRefs: baseRefs,
}), nil
}
}
// If we haven't got a repo by now, and we can't prompt then it's game over.
if !opts.IO.CanPrompt() {
fmt.Fprintln(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag")
return nil, cmdutil.SilentError
}
// Otherwise, hooray, prompting!
// First, we're going to look at our remotes and decide whether there are any repos we can push to.
pushableRepos, err := resolvedRemotes.HeadRepos()
if err != nil {
return nil, err
}
// If we couldn't find any pushable repos, then find forks of the base repo.
if len(pushableRepos) == 0 {
pushableRepos, err = api.RepoFindForks(client, baseRepo, 3)
if err != nil {
return nil, err
}
}
currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost())
if err != nil {
return nil, err
}
hasOwnFork := false
var pushOptions []string
for _, r := range pushableRepos {
pushOptions = append(pushOptions, ghrepo.FullName(r))
if r.RepoOwner() == currentLogin {
hasOwnFork = true
}
}
if !hasOwnFork {
pushOptions = append(pushOptions, fmt.Sprintf("Create a fork of %s", ghrepo.FullName(baseRepo)))
}
pushOptions = append(pushOptions, "Skip pushing the branch")
pushOptions = append(pushOptions, "Cancel")
selectedOption, err := opts.Prompter.Select(fmt.Sprintf("Where should we push the '%s' branch?", currentBranch), "", pushOptions)
if err != nil {
return nil, err
}
if selectedOption < len(pushableRepos) {
// A repository has been selected to push to.
return newCreateContext(pushableRefs{
headRepo: pushableRepos[selectedOption],
headBranchName: currentBranch,
baseRefs: baseRefs,
}), nil
} else if pushOptions[selectedOption] == "Skip pushing the branch" {
// We're going to skip pushing the branch altogether, meaning, use whatever SHA is already pushed.
// It's not exactly clear what repo the user expects to use here for the HEAD, and maybe we should
// make that clear in the UX somehow, but in the old implementation as far as I can tell, this
// always meant "use the base repo".
return newCreateContext(skipPushRefs{
qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner(currentBranch),
baseRefs: baseRefs,
}), nil
} else if pushOptions[selectedOption] == "Cancel" {
return nil, cmdutil.CancelError
} else {
// A fork should be created.
return newCreateContext(forkableRefs{
qualifiedHeadRef: shared.NewQualifiedHeadRef(currentLogin, currentBranch),
baseRefs: baseRefs,
}), nil
}
}
func getRemotes(opts *CreateOptions) (ghContext.Remotes, error) {
@ -812,8 +973,8 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS
"title": state.Title,
"body": state.Body,
"draft": state.Draft,
"baseRefName": ctx.BaseBranch,
"headRefName": ctx.HeadBranchLabel,
"baseRefName": ctx.PRRefs.BaseRef(),
"headRefName": ctx.PRRefs.QualifiedHeadRef(),
"maintainerCanModify": opts.MaintainerCanModify,
}
@ -821,7 +982,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS
return errors.New("pull request title must not be blank")
}
err := shared.AddMetadataToIssueParams(client, ctx.BaseRepo, params, &state)
err := shared.AddMetadataToIssueParams(client, ctx.PRRefs.BaseRepo(), params, &state)
if err != nil {
return err
}
@ -835,7 +996,7 @@ func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataS
}
opts.IO.StartProgressIndicator()
pr, err := api.CreatePullRequest(client, ctx.BaseRepo, params)
pr, err := api.CreatePullRequest(client, ctx.PRRefs.BaseRepo(), params)
opts.IO.StopProgressIndicator()
if pr != nil {
fmt.Fprintln(opts.IO.Out, pr.URL)
@ -879,37 +1040,37 @@ func renderPullRequestPlain(w io.Writer, params map[string]interface{}, state *s
}
func renderPullRequestTTY(io *iostreams.IOStreams, params map[string]interface{}, state *shared.IssueMetadataState) error {
iofmt := io.ColorScheme()
cs := io.ColorScheme()
out := io.Out
fmt.Fprint(out, "Would have created a Pull Request with:\n")
fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Title"), params["title"].(string))
fmt.Fprintf(out, "%s: %t\n", iofmt.Bold("Draft"), params["draft"])
fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Base"), params["baseRefName"])
fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Head"), params["headRefName"])
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Title"), params["title"].(string))
fmt.Fprintf(out, "%s: %t\n", cs.Bold("Draft"), params["draft"])
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Base"), params["baseRefName"])
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Head"), params["headRefName"])
if len(state.Labels) != 0 {
fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Labels"), strings.Join(state.Labels, ", "))
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Labels"), strings.Join(state.Labels, ", "))
}
if len(state.Reviewers) != 0 {
fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Reviewers"), strings.Join(state.Reviewers, ", "))
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Reviewers"), strings.Join(state.Reviewers, ", "))
}
if len(state.Assignees) != 0 {
fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Assignees"), strings.Join(state.Assignees, ", "))
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Assignees"), strings.Join(state.Assignees, ", "))
}
if len(state.Milestones) != 0 {
fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Milestones"), strings.Join(state.Milestones, ", "))
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Milestones"), strings.Join(state.Milestones, ", "))
}
if len(state.Projects) != 0 {
fmt.Fprintf(out, "%s: %s\n", iofmt.Bold("Projects"), strings.Join(state.Projects, ", "))
fmt.Fprintf(out, "%s: %s\n", cs.Bold("Projects"), strings.Join(state.Projects, ", "))
}
fmt.Fprintf(out, "%s: %t\n", iofmt.Bold("MaintainerCanModify"), params["maintainerCanModify"])
fmt.Fprintf(out, "%s: %t\n", cs.Bold("MaintainerCanModify"), params["maintainerCanModify"])
fmt.Fprintf(out, "%s\n", iofmt.Bold("Body:"))
fmt.Fprintf(out, "%s\n", cs.Bold("Body:"))
// Body
var md string
var err error
if len(params["body"].(string)) == 0 {
md = fmt.Sprintf("%s\n", iofmt.Gray("No description provided"))
md = fmt.Sprintf("%s\n", cs.Muted("No description provided"))
} else {
md, err = markdown.Render(params["body"].(string),
markdown.WithTheme(io.TerminalTheme()),
@ -931,38 +1092,43 @@ func previewPR(opts CreateOptions, openURL string) error {
}
func handlePush(opts CreateOptions, ctx CreateContext) error {
didForkRepo := false
headRepo := ctx.HeadRepo
headRemote := ctx.HeadRemote
client := ctx.Client
gitClient := ctx.GitClient
var err error
// if a head repository could not be determined so far, automatically create
// one by forking the base repository
if headRepo == nil && ctx.IsPushEnabled {
refs := ctx.PRRefs
forkableRefs, requiresFork := refs.(forkableRefs)
if requiresFork {
opts.IO.StartProgressIndicator()
headRepo, err = api.ForkRepo(client, ctx.BaseRepo, "", "", false)
forkedRepo, err := api.ForkRepo(ctx.Client, forkableRefs.BaseRepo(), "", "", false)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error forking repo: %w", err)
}
didForkRepo = true
refs = pushableRefs{
headRepo: forkedRepo,
headBranchName: forkableRefs.qualifiedHeadRef.BranchName(),
baseRefs: baseRefs{
baseRepo: forkableRefs.baseRepo,
baseBranchName: forkableRefs.baseBranchName,
},
}
}
if headRemote == nil && headRepo != nil {
headRemote, _ = ctx.RepoContext.RemoteForRepo(headRepo)
// We may have upcast to pushableRefs on fork, or we may have been passed an instance
// already. But if we haven't, then there's nothing more to do.
pushableRefs, ok := refs.(pushableRefs)
if !ok {
return nil
}
// There are two cases when an existing remote for the head repo will be
// missing:
// missing (and an error will be returned):
// 1. the head repo was just created by auto-forking;
// 2. an existing fork was discovered by querying the API.
// In either case, we want to add the head repo as a new git remote so we
// can push to it. We will try to add the head repo as the "origin" remote
// and fallback to the "fork" remote if it is unavailable. Also, if the
// base repo is the "origin" remote we will rename it "upstream".
if headRemote == nil && ctx.IsPushEnabled {
headRemote, _ := ctx.ResolvedRemotes.RemoteForRepo(pushableRefs.HeadRepo())
if headRemote == nil {
cfg, err := opts.Config()
if err != nil {
return err
@ -973,8 +1139,8 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
return err
}
cloneProtocol := cfg.GitProtocol(headRepo.RepoHost()).Value
headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol)
cloneProtocol := cfg.GitProtocol(pushableRefs.HeadRepo().RepoHost()).Value
headRepoURL := ghrepo.FormatRemoteURL(pushableRefs.HeadRepo(), cloneProtocol)
gitClient := ctx.GitClient
origin, _ := remotes.FindByName("origin")
upstreamName := "upstream"
@ -985,7 +1151,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
remoteName = "fork"
}
if origin != nil && upstream == nil && ghrepo.IsSame(origin, ctx.BaseRepo) {
if origin != nil && upstream == nil && ghrepo.IsSame(origin, pushableRefs.BaseRepo()) {
renameCmd, err := gitClient.Command(context.Background(), "remote", "rename", "origin", upstreamName)
if err != nil {
return err
@ -994,7 +1160,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
return fmt.Errorf("error renaming origin remote: %w", err)
}
remoteName = "origin"
fmt.Fprintf(opts.IO.ErrOut, "Changed %s remote to %q\n", ghrepo.FullName(ctx.BaseRepo), upstreamName)
fmt.Fprintf(opts.IO.ErrOut, "Changed %s remote to %q\n", ghrepo.FullName(pushableRefs.BaseRepo()), upstreamName)
}
gitRemote, err := gitClient.AddRemote(context.Background(), remoteName, headRepoURL, []string{})
@ -1002,10 +1168,10 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
return fmt.Errorf("error adding remote: %w", err)
}
fmt.Fprintf(opts.IO.ErrOut, "Added %s as remote %q\n", ghrepo.FullName(headRepo), remoteName)
fmt.Fprintf(opts.IO.ErrOut, "Added %s as remote %q\n", ghrepo.FullName(pushableRefs.HeadRepo()), remoteName)
// Only mark `upstream` remote as default if `gh pr create` created the remote.
if didForkRepo {
if requiresFork {
err := gitClient.SetRemoteResolution(context.Background(), upstreamName, "base")
if err != nil {
return fmt.Errorf("error setting upstream as default: %w", err)
@ -1013,52 +1179,45 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
if opts.IO.IsStdoutTTY() {
cs := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(headRepo)))
fmt.Fprintf(opts.IO.ErrOut, "%s Repository %s set as the default repository. To learn more about the default repository, run: gh repo set-default --help\n", cs.WarningIcon(), cs.Bold(ghrepo.FullName(pushableRefs.HeadRepo())))
}
}
headRemote = &ghContext.Remote{
Remote: gitRemote,
Repo: headRepo,
Repo: pushableRefs.HeadRepo(),
}
}
// automatically push the branch if it hasn't been pushed anywhere yet
if ctx.IsPushEnabled {
pushBranch := func() error {
w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "")
defer w.Flush()
ref := fmt.Sprintf("HEAD:refs/heads/%s", ctx.HeadBranch)
bo := backoff.NewConstantBackOff(2 * time.Second)
ctx := context.Background()
return backoff.Retry(func() error {
if err := gitClient.Push(ctx, headRemote.Name, ref, git.WithStderr(w)); err != nil {
// Only retry if we have forked the repo else the push should succeed the first time.
if didForkRepo {
fmt.Fprintf(opts.IO.ErrOut, "waiting 2 seconds before retrying...\n")
return err
}
return backoff.Permanent(err)
pushBranch := func() error {
w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "")
defer w.Flush()
ref := fmt.Sprintf("HEAD:refs/heads/%s", ctx.PRRefs.UnqualifiedHeadRef())
bo := backoff.NewConstantBackOff(2 * time.Second)
root := context.Background()
return backoff.Retry(func() error {
if err := ctx.GitClient.Push(root, headRemote.Name, ref, git.WithStderr(w)); err != nil {
// Only retry if we have forked the repo else the push should succeed the first time.
if requiresFork {
fmt.Fprintf(opts.IO.ErrOut, "waiting 2 seconds before retrying...\n")
return err
}
return nil
}, backoff.WithContext(backoff.WithMaxRetries(bo, 3), ctx))
}
err := pushBranch()
if err != nil {
return err
}
return backoff.Permanent(err)
}
return nil
}, backoff.WithContext(backoff.WithMaxRetries(bo, 3), root))
}
return nil
return pushBranch()
}
func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (string, error) {
u := ghrepo.GenerateRepoURL(
ctx.BaseRepo,
ctx.PRRefs.BaseRepo(),
"compare/%s...%s?expand=1",
url.PathEscape(ctx.BaseBranch), url.PathEscape(ctx.HeadBranchLabel))
url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.BaseRepo, u, state)
url.PathEscape(ctx.PRRefs.BaseRef()), url.PathEscape(ctx.PRRefs.QualifiedHeadRef()))
url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PRRefs.BaseRepo(), u, state)
if err != nil {
return "", err
}

View file

@ -2,7 +2,6 @@ package create
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
@ -607,7 +606,7 @@ func Test_createRun(t *testing.T) {
`),
},
{
name: "survey",
name: "select a specific branch to push to on prompt",
tty: true,
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.TitleProvided = true
@ -636,7 +635,9 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 1, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
},
promptStubs: func(pm *prompter.PrompterMock) {
@ -651,6 +652,52 @@ func Test_createRun(t *testing.T) {
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
},
{
name: "skip pushing to branch on prompt",
tty: true,
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.TitleProvided = true
opts.BodyProvided = true
opts.Title = "my title"
opts.Body = "my body"
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.StubRepoResponse("OWNER", "REPO")
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
reg.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }`, func(input map[string]interface{}) {
assert.Equal(t, "REPOID", input["repositoryId"].(string))
assert.Equal(t, "my title", input["title"].(string))
assert.Equal(t, "my body", input["body"].(string))
assert.Equal(t, "master", input["baseRefName"].(string))
assert.Equal(t, "feature", input["headRefName"].(string))
assert.Equal(t, false, input["draft"].(bool))
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 1, "")
},
promptStubs: func(pm *prompter.PrompterMock) {
pm.SelectFunc = func(p, _ string, opts []string) (int, error) {
if p == "Where should we push the 'feature' branch?" {
return prompter.IndexFor(opts, "Skip pushing the branch")
} else {
return -1, prompter.NoSuchPromptErr(p)
}
}
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
},
{
name: "project v2",
tty: true,
@ -699,7 +746,9 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
},
promptStubs: func(pm *prompter.PrompterMock) {
@ -745,7 +794,9 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
},
promptStubs: func(pm *prompter.PrompterMock) {
@ -794,7 +845,10 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register("git rev-parse --symbolic-full-name feature@{push}", 1, "")
cs.Register("git config remote.pushDefault", 1, "")
cs.Register("git config push.default", 1, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
cs.Register("git remote rename origin upstream", 0, "")
cs.Register(`git remote add origin https://github.com/monalisa/REPO.git`, 0, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
@ -853,10 +907,10 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register("git show-ref --verify", 0, heredoc.Doc(`
deadbeef HEAD
deadb00f refs/remotes/upstream/feature
deadbeef refs/remotes/origin/feature`)) // determineTrackingBranch
cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/feature", 0, heredoc.Doc(`
deadbeef HEAD
deadbeef refs/remotes/origin/feature`))
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
expectedErrOut: "\nCreating pull request for monalisa:feature into master in OWNER/REPO\n\n",
@ -889,11 +943,12 @@ func Test_createRun(t *testing.T) {
cs.Register(`git config --get-regexp \^branch\\\.feature\\\.`, 0, heredoc.Doc(`
branch.feature.remote origin
branch.feature.merge refs/heads/my-feat2
`)) // determineTrackingBranch
cs.Register("git show-ref --verify", 0, heredoc.Doc(`
`))
cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/my-feat2")
cs.Register("git show-ref --verify -- HEAD refs/remotes/origin/my-feat2", 0, heredoc.Doc(`
deadbeef HEAD
deadbeef refs/remotes/origin/my-feat2
`)) // determineTrackingBranch
`))
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
expectedErrOut: "\nCreating pull request for my-feat2 into master in OWNER/REPO\n\n",
@ -1073,8 +1128,10 @@ func Test_createRun(t *testing.T) {
httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "")
cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
},
promptStubs: func(pm *prompter.PrompterMock) {
@ -1105,8 +1162,10 @@ func Test_createRun(t *testing.T) {
mockRetrieveProjects(t, reg)
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "")
cs.Register("git rev-parse --symbolic-full-name feature@{push}", 0, "refs/remotes/origin/feature")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 1, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
},
promptStubs: func(pm *prompter.PrompterMock) {
@ -1271,31 +1330,6 @@ func Test_createRun(t *testing.T) {
},
wantErr: "cannot open in browser: maximum URL length exceeded",
},
{
name: "no local git repo",
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.Title = "My PR"
opts.TitleProvided = true
opts.Body = ""
opts.BodyProvided = true
opts.HeadBranch = "feature"
opts.RepoOverride = "OWNER/REPO"
opts.Remotes = func() (context.Remotes, error) {
return nil, errors.New("not a git repository")
}
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.StringResponse(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`))
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
},
{
name: "single commit title and body are used",
tty: true,
@ -1520,19 +1554,45 @@ func Test_createRun(t *testing.T) {
branch.task1.remote origin
branch.task1.merge refs/heads/task1
branch.task1.gh-merge-base feature/feat2`)) // ReadBranchConfig
cs.Register(`git show-ref --verify`, 0, heredoc.Doc(`
cs.Register("git rev-parse --symbolic-full-name task1@{push}", 0, "refs/remotes/origin/task1")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/task1`, 0, heredoc.Doc(`
deadbeef HEAD
deadb00f refs/remotes/upstream/feature/feat2
deadbeef refs/remotes/origin/task1`)) // determineTrackingBranch
deadbeef refs/remotes/origin/task1`))
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
expectedErrOut: "\nCreating pull request for monalisa:task1 into feature/feat2 in OWNER/REPO\n\n",
},
{
name: "--head contains <user>:<branch> syntax",
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }`,
func(input map[string]interface{}) {
assert.Equal(t, "REPOID", input["repositoryId"])
assert.Equal(t, "my title", input["title"])
assert.Equal(t, "my body", input["body"])
assert.Equal(t, "master", input["baseRefName"])
assert.Equal(t, "otherowner:feature", input["headRefName"])
}))
},
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.TitleProvided = true
opts.BodyProvided = true
opts.Title = "my title"
opts.Body = "my body"
opts.HeadBranch = "otherowner:feature"
return func() {}
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
branch := "feature"
reg := &httpmock.Registry{}
reg.StubRepoInfoResponse("OWNER", "REPO", "master")
defer reg.Verify(t)
@ -1548,7 +1608,7 @@ func Test_createRun(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git status --porcelain`, 0, "")
if !tt.customBranchConfig {
cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
}
@ -1599,6 +1659,10 @@ func Test_createRun(t *testing.T) {
}
defer cleanSetup()
if opts.HeadBranch == "" {
cs.Register(`git status --porcelain`, 0, "")
}
err := createRun(&opts)
output := &test.CmdOut{
OutBuf: stdout,
@ -1622,109 +1686,166 @@ func Test_createRun(t *testing.T) {
}
}
func Test_tryDetermineTrackingRef(t *testing.T) {
tests := []struct {
name string
cmdStubs func(*run.CommandStubber)
headBranchConfig git.BranchConfig
remotes context.Remotes
expectedTrackingRef trackingRef
expectedFound bool
}{
{
name: "empty",
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- HEAD`, 0, "abc HEAD")
},
headBranchConfig: git.BranchConfig{},
expectedTrackingRef: trackingRef{},
expectedFound: false,
func TestRemoteGuessing(t *testing.T) {
// Given git config does not provide the necessary info to determine a remote
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git status --porcelain`, 0, "")
cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
cs.Register(`git rev-parse --symbolic-full-name feature@{push}`, 1, "")
cs.Register("git config remote.pushDefault", 1, "")
cs.Register("git config push.default", 1, "")
// And Given there is a remote on a SHA that matches the current HEAD
cs.Register(`git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature`, 0, heredoc.Doc(`
deadbeef HEAD
deadb00f refs/remotes/upstream/feature
deadbeef refs/remotes/origin/feature`))
// When the command is run
reg := &httpmock.Registry{}
reg.StubRepoInfoResponse("OWNER", "REPO", "master")
defer reg.Verify(t)
reg.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }`, func(input map[string]interface{}) {
assert.Equal(t, "REPOID", input["repositoryId"].(string))
assert.Equal(t, "master", input["baseRefName"].(string))
assert.Equal(t, "OTHEROWNER:feature", input["headRefName"].(string))
}))
ios, _, _, _ := iostreams.Test()
opts := CreateOptions{
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
{
name: "no match",
cmdStubs: func(cs *run.CommandStubber) {
cs.Register("git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature", 0, "abc HEAD\nbca refs/remotes/upstream/feature")
},
headBranchConfig: git.BranchConfig{},
remotes: context.Remotes{
&context.Remote{
Remote: &git.Remote{Name: "upstream"},
Repo: ghrepo.New("octocat", "Spoon-Knife"),
},
&context.Remote{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("hubot", "Spoon-Knife"),
},
},
expectedTrackingRef: trackingRef{},
expectedFound: false,
Config: func() (gh.Config, error) {
return config.NewBlankConfig(), nil
},
{
name: "match",
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- HEAD refs/remotes/upstream/feature refs/remotes/origin/feature$`, 0, heredoc.Doc(`
deadbeef HEAD
deadb00f refs/remotes/upstream/feature
deadbeef refs/remotes/origin/feature
`))
},
headBranchConfig: git.BranchConfig{},
remotes: context.Remotes{
&context.Remote{
Remote: &git.Remote{Name: "upstream"},
Repo: ghrepo.New("octocat", "Spoon-Knife"),
},
&context.Remote{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("hubot", "Spoon-Knife"),
},
},
expectedTrackingRef: trackingRef{
remoteName: "origin",
branchName: "feature",
},
expectedFound: true,
Browser: &browser.Stub{},
IO: ios,
Prompter: &prompter.PrompterMock{},
GitClient: &git.Client{
GhPath: "some/path/gh",
GitPath: "some/path/git",
},
{
name: "respect tracking config",
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/great-feat refs/remotes/origin/feature$`, 0, heredoc.Doc(`
deadbeef HEAD
deadb00f refs/remotes/origin/feature
`))
},
headBranchConfig: git.BranchConfig{
RemoteName: "origin",
MergeRef: "refs/heads/great-feat",
},
remotes: context.Remotes{
&context.Remote{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("hubot", "Spoon-Knife"),
Finder: shared.NewMockFinder("feature", nil, nil),
Remotes: func() (context.Remotes, error) {
return context.Remotes{
{
Remote: &git.Remote{
Name: "upstream",
Resolved: "base",
},
Repo: ghrepo.New("OWNER", "REPO"),
},
},
expectedTrackingRef: trackingRef{},
expectedFound: false,
{
Remote: &git.Remote{
Name: "origin",
},
Repo: ghrepo.New("OTHEROWNER", "REPO-FORK"),
},
}, nil
},
Branch: func() (string, error) {
return "feature", nil
},
TitleProvided: true,
BodyProvided: true,
Title: "my title",
Body: "my body",
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
tt.cmdStubs(cs)
require.NoError(t, createRun(&opts))
gitClient := &git.Client{
GhPath: "some/path/gh",
GitPath: "some/path/git",
}
// Then guessed remote is used for the PR head,
// which annoyingly, is asserted above on the line:
// assert.Equal(t, "OTHEROWNER:feature", input["headRefName"].(string))
//
// This is because OTHEROWNER relates to the "origin" remote, which has a
// SHA that matches the HEAD ref in the `git show-ref` output.
}
ref, found := tryDetermineTrackingRef(gitClient, tt.remotes, "feature", tt.headBranchConfig)
func TestNoRepoCanBeDetermined(t *testing.T) {
// Given no head repo can be determined from git config
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
assert.Equal(t, tt.expectedTrackingRef, ref)
assert.Equal(t, tt.expectedFound, found)
})
cs.Register(`git status --porcelain`, 0, "")
cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
cs.Register(`git rev-parse --symbolic-full-name feature@{push}`, 1, "")
cs.Register("git config remote.pushDefault", 1, "")
cs.Register("git config push.default", 1, "")
// And Given there is no remote on the correct SHA
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, heredoc.Doc(`
deadbeef HEAD
deadb00f refs/remotes/origin/feature`))
// When the command is run with no TTY
reg := &httpmock.Registry{}
reg.StubRepoInfoResponse("OWNER", "REPO", "master")
defer reg.Verify(t)
ios, _, _, stderr := iostreams.Test()
opts := CreateOptions{
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
Config: func() (gh.Config, error) {
return config.NewBlankConfig(), nil
},
Browser: &browser.Stub{},
IO: ios,
Prompter: &prompter.PrompterMock{},
GitClient: &git.Client{
GhPath: "some/path/gh",
GitPath: "some/path/git",
},
Finder: shared.NewMockFinder("feature", nil, nil),
Remotes: func() (context.Remotes, error) {
return context.Remotes{
{
Remote: &git.Remote{
Name: "origin",
Resolved: "base",
},
Repo: ghrepo.New("OWNER", "REPO"),
},
}, nil
},
Branch: func() (string, error) {
return "feature", nil
},
TitleProvided: true,
BodyProvided: true,
Title: "my title",
Body: "my body",
}
// When we run the command
err := createRun(&opts)
// Then create fails
require.Equal(t, cmdutil.SilentError, err)
assert.Equal(t, "aborted: you must first push the current branch to a remote, or use the --head flag\n", stderr.String())
}
func mustParseQualifiedHeadRef(ref string) shared.QualifiedHeadRef {
parsed, err := shared.ParseQualifiedHeadRef(ref)
if err != nil {
panic(err)
}
return parsed
}
func Test_generateCompareURL(t *testing.T) {
@ -1738,9 +1859,13 @@ func Test_generateCompareURL(t *testing.T) {
{
name: "basic",
ctx: CreateContext{
BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
BaseBranch: "main",
HeadBranchLabel: "feature",
PRRefs: &skipPushRefs{
qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"),
baseRefs: baseRefs{
baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
baseBranchName: "main",
},
},
},
want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1",
wantErr: false,
@ -1748,9 +1873,13 @@ func Test_generateCompareURL(t *testing.T) {
{
name: "with labels",
ctx: CreateContext{
BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
BaseBranch: "a",
HeadBranchLabel: "b",
PRRefs: &skipPushRefs{
qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("b"),
baseRefs: baseRefs{
baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
baseBranchName: "a",
},
},
},
state: shared.IssueMetadataState{
Labels: []string{"one", "two three"},
@ -1761,35 +1890,47 @@ func Test_generateCompareURL(t *testing.T) {
{
name: "'/'s in branch names/labels are percent-encoded",
ctx: CreateContext{
BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
BaseBranch: "main/trunk",
HeadBranchLabel: "owner:feature",
PRRefs: &skipPushRefs{
qualifiedHeadRef: mustParseQualifiedHeadRef("ORIGINOWNER:feature"),
baseRefs: baseRefs{
baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"),
baseBranchName: "main/trunk",
},
},
},
want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner:feature?body=&expand=1",
want: "https://github.com/UPSTREAMOWNER/REPO/compare/main%2Ftrunk...ORIGINOWNER:feature?body=&expand=1",
wantErr: false,
},
{
name: "Any of !'(),; but none of $&+=@ and : in branch names/labels are percent-encoded ",
/*
- Technically, per section 3.3 of RFC 3986, none of !$&'()*+,;= (sub-delims) and :[]@ (part of gen-delims) in path segments are optionally percent-encoded, but url.PathEscape percent-encodes !'(),; anyway
- !$&'()+,;=@ is a valid Git branch nameessentially RFC 3986 sub-delims without * and gen-delims without :/?#[]
- : is GitHub separator between a fork name and a branch name
- See https://github.com/golang/go/issues/27559.
- Technically, per section 3.3 of RFC 3986, none of !$&'()*+,;= (sub-delims) and :[]@ (part of gen-delims) in path segments are optionally percent-encoded, but url.PathEscape percent-encodes !'(),; anyway
- !$&'()+,;=@ is a valid Git branch nameessentially RFC 3986 sub-delims without * and gen-delims without :/?#[]
- : is GitHub separator between a fork name and a branch name
- See https://github.com/golang/go/issues/27559.
*/
ctx: CreateContext{
BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
BaseBranch: "main/trunk",
HeadBranchLabel: "owner:!$&'()+,;=@",
PRRefs: &skipPushRefs{
qualifiedHeadRef: mustParseQualifiedHeadRef("ORIGINOWNER:!$&'()+,;=@"),
baseRefs: baseRefs{
baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "UPSTREAMOWNER"}}, "github.com"),
baseBranchName: "main/trunk",
},
},
},
want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner:%21$&%27%28%29+%2C%3B=@?body=&expand=1",
want: "https://github.com/UPSTREAMOWNER/REPO/compare/main%2Ftrunk...ORIGINOWNER:%21$&%27%28%29+%2C%3B=@?body=&expand=1",
wantErr: false,
},
{
name: "with template",
ctx: CreateContext{
BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
BaseBranch: "main",
HeadBranchLabel: "feature",
PRRefs: &skipPushRefs{
qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"),
baseRefs: baseRefs{
baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
baseBranchName: "main",
},
},
},
state: shared.IssueMetadataState{
Template: "story.md",

View file

@ -191,7 +191,7 @@ func reviewRun(opts *ReviewOptions) error {
switch reviewData.State {
case api.ReviewComment:
fmt.Fprintf(opts.IO.ErrOut, "%s Reviewed pull request %s#%d\n", cs.Gray("-"), ghrepo.FullName(baseRepo), pr.Number)
fmt.Fprintf(opts.IO.ErrOut, "%s Reviewed pull request %s#%d\n", cs.Muted("-"), ghrepo.FullName(baseRepo), pr.Number)
case api.ReviewApprove:
fmt.Fprintf(opts.IO.ErrOut, "%s Approved pull request %s#%d\n", cs.SuccessIcon(), ghrepo.FullName(baseRepo), pr.Number)
case api.ReviewRequestChanges:

View file

@ -62,7 +62,7 @@ func CommentList(io *iostreams.IOStreams, comments api.Comments, reviews api.Pul
hiddenCount := totalCount - retrievedCount
if preview && hiddenCount > 0 {
fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Not showing %s ————————", text.Pluralize(hiddenCount, "comment"))))
fmt.Fprint(&b, cs.Muted(fmt.Sprintf("———————— Not showing %s ————————", text.Pluralize(hiddenCount, "comment"))))
fmt.Fprintf(&b, "\n\n\n")
}
@ -79,7 +79,7 @@ func CommentList(io *iostreams.IOStreams, comments api.Comments, reviews api.Pul
}
if preview && hiddenCount > 0 {
fmt.Fprint(&b, cs.Gray("Use --comments to view the full conversation"))
fmt.Fprint(&b, cs.Muted("Use --comments to view the full conversation"))
fmt.Fprintln(&b)
}
@ -122,7 +122,7 @@ func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (strin
var md string
var err error
if comment.Content() == "" {
md = fmt.Sprintf("\n %s\n\n", cs.Gray("No body provided"))
md = fmt.Sprintf("\n %s\n\n", cs.Muted("No body provided"))
} else {
md, err = markdown.Render(comment.Content(),
markdown.WithTheme(io.TerminalTheme()),
@ -135,7 +135,7 @@ func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (strin
// Footer
if comment.Link() != "" {
fmt.Fprintf(&b, cs.Gray("View the full review: %s\n\n"), comment.Link())
fmt.Fprintf(&b, cs.Muted("View the full review: %s\n\n"), comment.Link())
}
return b.String(), nil

View file

@ -60,7 +60,7 @@ func PrintHeader(io *iostreams.IOStreams, s string) {
}
func PrintMessage(io *iostreams.IOStreams, s string) {
fmt.Fprintln(io.Out, io.ColorScheme().Gray(s))
fmt.Fprintln(io.Out, io.ColorScheme().Muted(s))
}
func ListNoResults(repoName string, itemName string, hasFilters bool) error {
@ -83,7 +83,7 @@ func ListHeader(repoName string, itemName string, matchCount int, totalMatchCoun
}
func PrCheckStatusSummaryWithColor(cs *iostreams.ColorScheme, checks api.PullRequestChecksStatus) string {
var summary = cs.Gray("No checks")
var summary = cs.Muted("No checks")
if checks.Total > 0 {
if checks.Failing > 0 {
if checks.Failing == checks.Total {

View file

@ -0,0 +1,394 @@
package shared
import (
"context"
"fmt"
"net/url"
"strings"
ghContext "github.com/cli/cli/v2/context"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
o "github.com/cli/cli/v2/pkg/option"
)
// QualifiedHeadRef represents a git branch with an optional owner, used
// for the head of a pull request. For example, within a single repository,
// we would expect a PR to have a head ref of no owner, and a branch name.
// However, for cross-repository pull requests, we would expect a head ref
// with an owner and a branch name. In string form this is represented as
// <owner>:<branch>. The GitHub API is able to interpret this format in order
// to discover the correct fork repository.
//
// In other parts of the code, you may see this refered to as a HeadLabel.
type QualifiedHeadRef struct {
owner o.Option[string]
branchName string
}
// NewQualifiedHeadRef creates a QualifiedHeadRef. If the empty string is provided
// for the owner, it will be treated as None.
func NewQualifiedHeadRef(owner string, branchName string) QualifiedHeadRef {
return QualifiedHeadRef{
owner: o.SomeIfNonZero(owner),
branchName: branchName,
}
}
func NewQualifiedHeadRefWithoutOwner(branchName string) QualifiedHeadRef {
return QualifiedHeadRef{
owner: o.None[string](),
branchName: branchName,
}
}
// ParseQualifiedHeadRef takes strings of the form <owner>:<branch> or <branch>
// and returns a QualifiedHeadRef. If the form <owner>:<branch> is used,
// the owner is set to the value of <owner>, and the branch name is set to
// the value of <branch>. If the form <branch> is used, the owner is set to
// None, and the branch name is set to the value of <branch>.
//
// This does no further error checking about the validity of a ref, so
// it is not safe to assume the ref is truly a valid ref, e.g. "my~bad:ref?"
// is going to result in a nonsense result.
func ParseQualifiedHeadRef(ref string) (QualifiedHeadRef, error) {
if !strings.Contains(ref, ":") {
return NewQualifiedHeadRefWithoutOwner(ref), nil
}
parts := strings.Split(ref, ":")
if len(parts) != 2 {
return QualifiedHeadRef{}, fmt.Errorf("invalid qualified head ref format '%s'", ref)
}
return NewQualifiedHeadRef(parts[0], parts[1]), nil
}
// A QualifiedHeadRef without an owner returns <branch>, while a QualifiedHeadRef
// with an owner returns <owner>:<branch>.
func (r QualifiedHeadRef) String() string {
if owner, present := r.owner.Value(); present {
return fmt.Sprintf("%s:%s", owner, r.branchName)
}
return r.branchName
}
func (r QualifiedHeadRef) BranchName() string {
return r.branchName
}
// PRFindRefs represents the necessary data to find a pull request from the API.
type PRFindRefs struct {
qualifiedHeadRef QualifiedHeadRef
baseRepo ghrepo.Interface
// baseBranchName is an optional branch name, because it is not required for
// finding a pull request, only for disambiguation if multiple pull requests
// contain the same head ref.
baseBranchName o.Option[string]
}
// QualifiedHeadRef returns a stringified form of the head ref, varying depending
// on whether the head ref is in the same repository as the base ref. If they are
// the same repository, we return the branch name only. If they are different repositories,
// we return the owner and branch name in the form <owner>:<branch>.
func (r PRFindRefs) QualifiedHeadRef() string {
return r.qualifiedHeadRef.String()
}
func (r PRFindRefs) UnqualifiedHeadRef() string {
return r.qualifiedHeadRef.BranchName()
}
// Matches checks whether the provided baseBranchName and headRef match the refs.
// It is used to determine whether Pull Requests returned from the API
func (r PRFindRefs) Matches(baseBranchName, qualifiedHeadRef string) bool {
headMatches := qualifiedHeadRef == r.QualifiedHeadRef()
baseMatches := r.baseBranchName.IsNone() || baseBranchName == r.baseBranchName.Unwrap()
return headMatches && baseMatches
}
func (r PRFindRefs) BaseRepo() ghrepo.Interface {
return r.baseRepo
}
type RemoteNameToRepoFn func(remoteName string) (ghrepo.Interface, error)
// PullRequestFindRefsResolver interrogates git configuration to try and determine
// a head repository and a remote branch name, from a local branch name.
type PullRequestFindRefsResolver struct {
GitConfigClient GitConfigClient
RemoteNameToRepoFn RemoteNameToRepoFn
}
func NewPullRequestFindRefsResolver(gitConfigClient GitConfigClient, remotesFn func() (ghContext.Remotes, error)) PullRequestFindRefsResolver {
return PullRequestFindRefsResolver{
GitConfigClient: gitConfigClient,
RemoteNameToRepoFn: newRemoteNameToRepoFn(remotesFn),
}
}
// ResolvePullRequests takes a base repository, a base branch name and a local branch name and uses the git configuration to
// determine the head repository and remote branch name. If we were unable to determine this from git, we default the head
// repository to the base repository.
func (r *PullRequestFindRefsResolver) ResolvePullRequestRefs(baseRepo ghrepo.Interface, baseBranchName, localBranchName string) (PRFindRefs, error) {
if baseRepo == nil {
return PRFindRefs{}, fmt.Errorf("find pull request ref resolution cannot be performed without a base repository")
}
if localBranchName == "" {
return PRFindRefs{}, fmt.Errorf("find pull request ref resolution cannot be performed without a local branch name")
}
headPRRef, err := TryDetermineDefaultPRHead(r.GitConfigClient, remoteToRepoResolver{r.RemoteNameToRepoFn}, localBranchName)
if err != nil {
return PRFindRefs{}, err
}
// If the headRepo was resolved, we can just convert the response
// to refs and return it.
if headRepo, present := headPRRef.Repo.Value(); present {
qualifiedHeadRef := NewQualifiedHeadRefWithoutOwner(headPRRef.BranchName)
if !ghrepo.IsSame(headRepo, baseRepo) {
qualifiedHeadRef = NewQualifiedHeadRef(headRepo.RepoOwner(), headPRRef.BranchName)
}
return PRFindRefs{
qualifiedHeadRef: qualifiedHeadRef,
baseRepo: baseRepo,
baseBranchName: o.SomeIfNonZero(baseBranchName),
}, nil
}
// If we didn't find a head repo, default to the base repo
return PRFindRefs{
qualifiedHeadRef: NewQualifiedHeadRefWithoutOwner(headPRRef.BranchName),
baseRepo: baseRepo,
baseBranchName: o.SomeIfNonZero(baseBranchName),
}, nil
}
// DefaultPRHead is a neighbour to defaultPushTarget, but instead of holding
// basic git remote information, it holds a resolved repository in `gh` terms.
//
// Since we may not be able to determine a default remote for a branch, this
// is also true of the resolved repository.
type DefaultPRHead struct {
Repo o.Option[ghrepo.Interface]
BranchName string
}
// TryDetermineDefaultPRHead is a thin wrapper around determineDefaultPushTarget, which attempts to convert
// a present remote into a resolved repository. If the remote is not present, we indicate that to the caller
// by returning a None value for the repo.
func TryDetermineDefaultPRHead(gitClient GitConfigClient, remoteToRepo remoteToRepoResolver, branch string) (DefaultPRHead, error) {
pushTarget, err := tryDetermineDefaultPushTarget(gitClient, branch)
if err != nil {
return DefaultPRHead{}, err
}
// If we have no remote, let the caller decide what to do by indicating that with a None.
if pushTarget.remote.IsNone() {
return DefaultPRHead{
Repo: o.None[ghrepo.Interface](),
BranchName: pushTarget.branchName,
}, nil
}
repo, err := remoteToRepo.resolve(pushTarget.remote.Unwrap())
if err != nil {
return DefaultPRHead{}, err
}
return DefaultPRHead{
Repo: o.Some(repo),
BranchName: pushTarget.branchName,
}, nil
}
// remote represents the value of the remote key in a branch's git configuration.
// This value may be a name or a URL, both of which are strings, but are unfortunately
// parsed by ReadBranchConfig into separate fields, allowing for illegal states to be
// created by accident. This is an attempt to indicate that they are mutally exclusive.
type remote interface{ sealedRemote() }
type remoteName struct{ name string }
func (rn remoteName) sealedRemote() {}
type remoteURL struct{ url *url.URL }
func (ru remoteURL) sealedRemote() {}
// newRemoteNameToRepoFn takes a function that returns a list of remotes and
// returns a function that takes a remote name and returns the corresponding
// repository. It is a convenience function to call sites having to duplicate
// the same logic.
func newRemoteNameToRepoFn(remotesFn func() (ghContext.Remotes, error)) RemoteNameToRepoFn {
return func(remoteName string) (ghrepo.Interface, error) {
remotes, err := remotesFn()
if err != nil {
return nil, err
}
repo, err := remotes.FindByName(remoteName)
if err != nil {
return nil, err
}
return repo, nil
}
}
// remoteToRepoResolver provides a utility method to resolve a remote (either name or URL)
// to a repo (ghrepo.Interface).
type remoteToRepoResolver struct {
remoteNameToRepo RemoteNameToRepoFn
}
func NewRemoteToRepoResolver(remotesFn func() (ghContext.Remotes, error)) remoteToRepoResolver {
return remoteToRepoResolver{
remoteNameToRepo: newRemoteNameToRepoFn(remotesFn),
}
}
// resolve takes a remote and returns a repository representing it.
func (r remoteToRepoResolver) resolve(remote remote) (ghrepo.Interface, error) {
switch v := remote.(type) {
case remoteName:
repo, err := r.remoteNameToRepo(v.name)
if err != nil {
return nil, fmt.Errorf("could not resolve remote %q: %w", v.name, err)
}
return repo, nil
case remoteURL:
repo, err := ghrepo.FromURL(v.url)
if err != nil {
return nil, fmt.Errorf("could not parse remote URL %q: %w", v.url, err)
}
return repo, nil
default:
return nil, fmt.Errorf("unsupported remote type %T, value: %v", v, remote)
}
}
// A defaultPushTarget represents the remote name or URL and a branch name
// that we would expect a branch to be pushed to if `git push` were run with
// no further arguments. This is the most likely place for the head of the PR
// to be, but it's not guaranteed. The user may have pushed to another branch
// directly via `git push <remote> <local>:<remote>` and not set up tracking information.
// A branch name is always present.
//
// It's possible that we're unable to determine a remote, if the user had pushed directly
// to a URL for example `git push <url> <branch>`, which is why it is optional. When present,
// the remote may either be a name or a URL.
type defaultPushTarget struct {
remote o.Option[remote]
branchName string
}
// newDefaultPushTarget is a thin wrapper over defaultPushTarget to help with
// generic type inference, to reduce verbosity in repeating the parametric type.
func newDefaultPushTarget(remote remote, branchName string) defaultPushTarget {
return defaultPushTarget{
remote: o.Some(remote),
branchName: branchName,
}
}
// tryDetermineDefaultPushTarget uses git configuration to make a best guess about where a branch
// is pushed to, and where it would be pushed to if the user ran `git push` with no additional
// arguments.
//
// Firstly, it attempts to resolve the @{push} ref, which is the most reliable method, as this
// is what git uses to determine the remote tracking branch
//
// If this fails, we go through a series of steps to determine the remote:
//
// 1. check branch configuration for `branch.<name>.pushRemote = <name> | <url>`
// 2. check remote configuration for `remote.pushDefault = <name>`
// 3. check branch configuration for `branch.<name>.remote = <name> | <url>`
//
// If none of these are set, we indicate that we were unable to determine the
// remote by returning a None value for the remote.
//
// The branch name is always set. The default configuration for push.default (current) indicates
// that a git push should use the same remote branch name as the local branch name. If push.default
// is set to upstream or tracking (deprecated form of upstream), then we use the branch name from the merge ref.
func tryDetermineDefaultPushTarget(gitClient GitConfigClient, localBranchName string) (defaultPushTarget, error) {
// If @{push} resolves, then we have the remote tracking branch already, no problem.
if pushRevisionRef, err := gitClient.PushRevision(context.Background(), localBranchName); err == nil {
return newDefaultPushTarget(remoteName{pushRevisionRef.Remote}, pushRevisionRef.Branch), nil
}
// But it doesn't always resolve, so we can suppress the error and move on to other means
// of determination. We'll first look at branch and remote configuration to make a determination.
branchConfig, err := gitClient.ReadBranchConfig(context.Background(), localBranchName)
if err != nil {
return defaultPushTarget{}, err
}
pushDefault, err := gitClient.PushDefault(context.Background())
if err != nil {
return defaultPushTarget{}, err
}
// We assume the PR's branch name is the same as whatever was provided, unless the user has specified
// push.default = upstream or tracking, then we use the branch name from the merge ref.
remoteBranch := localBranchName
if pushDefault == git.PushDefaultUpstream || pushDefault == git.PushDefaultTracking {
remoteBranch = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/")
if remoteBranch == "" {
return defaultPushTarget{}, fmt.Errorf("could not determine remote branch name")
}
}
// To get the remote, we look to the git config. It comes from one of the following, in order of precedence:
// 1. branch.<name>.pushRemote (which may be a name or a URL)
// 2. remote.pushDefault (which is a remote name)
// 3. branch.<name>.remote (which may be a name or a URL)
if branchConfig.PushRemoteName != "" {
return newDefaultPushTarget(
remoteName{branchConfig.PushRemoteName},
remoteBranch,
), nil
}
if branchConfig.PushRemoteURL != nil {
return newDefaultPushTarget(
remoteURL{branchConfig.PushRemoteURL},
remoteBranch,
), nil
}
remotePushDefault, err := gitClient.RemotePushDefault(context.Background())
if err != nil {
return defaultPushTarget{}, err
}
if remotePushDefault != "" {
return newDefaultPushTarget(
remoteName{remotePushDefault},
remoteBranch,
), nil
}
if branchConfig.RemoteName != "" {
return newDefaultPushTarget(
remoteName{branchConfig.RemoteName},
remoteBranch,
), nil
}
if branchConfig.RemoteURL != nil {
return newDefaultPushTarget(
remoteURL{branchConfig.RemoteURL},
remoteBranch,
), nil
}
// If we couldn't find the remote, we'll indicate that to the caller via None.
return defaultPushTarget{
remote: o.None[remote](),
branchName: remoteBranch,
}, nil
}

View file

@ -0,0 +1,508 @@
package shared
import (
"errors"
"net/url"
"testing"
ghContext "github.com/cli/cli/v2/context"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
o "github.com/cli/cli/v2/pkg/option"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestQualifiedHeadRef(t *testing.T) {
t.Parallel()
testCases := []struct {
behavior string
ref string
expectedString string
expectedBranchName string
expectedError error
}{
{
behavior: "when a branch is provided, the parsed qualified head ref only has a branch",
ref: "feature-branch",
expectedString: "feature-branch",
expectedBranchName: "feature-branch",
},
{
behavior: "when an owner and branch are provided, the parsed qualified head ref has both",
ref: "owner:feature-branch",
expectedString: "owner:feature-branch",
expectedBranchName: "feature-branch",
},
{
behavior: "when the structure cannot be interpreted correctly, an error is returned",
ref: "owner:feature-branch:extra",
expectedError: errors.New("invalid qualified head ref format 'owner:feature-branch:extra'"),
},
}
for _, tc := range testCases {
t.Run(tc.behavior, func(t *testing.T) {
t.Parallel()
qualifiedHeadRef, err := ParseQualifiedHeadRef(tc.ref)
if tc.expectedError != nil {
require.Equal(t, tc.expectedError, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.expectedString, qualifiedHeadRef.String())
assert.Equal(t, tc.expectedBranchName, qualifiedHeadRef.BranchName())
})
}
}
func TestPRFindRefs(t *testing.T) {
t.Parallel()
t.Run("qualified head ref with owner", func(t *testing.T) {
t.Parallel()
refs := PRFindRefs{
qualifiedHeadRef: mustParseQualifiedHeadRef("forkowner:feature-branch"),
}
require.Equal(t, "forkowner:feature-branch", refs.QualifiedHeadRef())
require.Equal(t, "feature-branch", refs.UnqualifiedHeadRef())
})
t.Run("qualified head ref without owner", func(t *testing.T) {
t.Parallel()
refs := PRFindRefs{
qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"),
}
require.Equal(t, "feature-branch", refs.QualifiedHeadRef())
require.Equal(t, "feature-branch", refs.UnqualifiedHeadRef())
})
t.Run("base repo", func(t *testing.T) {
t.Parallel()
refs := PRFindRefs{
baseRepo: ghrepo.New("owner", "repo"),
}
require.True(t, ghrepo.IsSame(refs.BaseRepo(), ghrepo.New("owner", "repo")), "expected repos to be the same")
})
t.Run("matches", func(t *testing.T) {
t.Parallel()
testCases := []struct {
behavior string
refs PRFindRefs
baseBranchName string
qualifiedHeadRef string
expectedMatch bool
}{
{
behavior: "when qualified head refs don't match, returns false",
refs: PRFindRefs{
qualifiedHeadRef: mustParseQualifiedHeadRef("owner:feature-branch"),
},
baseBranchName: "feature-branch",
qualifiedHeadRef: "feature-branch",
expectedMatch: false,
},
{
behavior: "when base branches don't match, returns false",
refs: PRFindRefs{
qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"),
baseBranchName: o.Some("not-main"),
},
baseBranchName: "main",
qualifiedHeadRef: "feature-branch",
expectedMatch: false,
},
{
behavior: "when head refs match and there is no base branch, returns true",
refs: PRFindRefs{
qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"),
baseBranchName: o.None[string](),
},
baseBranchName: "main",
qualifiedHeadRef: "feature-branch",
expectedMatch: true,
},
{
behavior: "when head refs match and base branches match, returns true",
refs: PRFindRefs{
qualifiedHeadRef: mustParseQualifiedHeadRef("feature-branch"),
baseBranchName: o.Some("main"),
},
baseBranchName: "main",
qualifiedHeadRef: "feature-branch",
expectedMatch: true,
},
}
for _, tc := range testCases {
t.Run(tc.behavior, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.expectedMatch, tc.refs.Matches(tc.baseBranchName, tc.qualifiedHeadRef))
})
}
})
}
func TestPullRequestResolution(t *testing.T) {
t.Parallel()
baseRepo := ghrepo.New("owner", "repo")
baseRemote := ghContext.Remote{
Remote: &git.Remote{
Name: "upstream",
},
Repo: ghrepo.New("owner", "repo"),
}
forkRemote := ghContext.Remote{
Remote: &git.Remote{
Name: "origin",
},
Repo: ghrepo.New("otherowner", "repo-fork"),
}
t.Run("when the base repo is nil, returns an error", func(t *testing.T) {
t.Parallel()
resolver := NewPullRequestFindRefsResolver(stubGitConfigClient{}, dummyRemotesFn)
_, err := resolver.ResolvePullRequestRefs(nil, "", "")
require.Error(t, err)
})
t.Run("when the local branch name is empty, returns an error", func(t *testing.T) {
t.Parallel()
resolver := NewPullRequestFindRefsResolver(stubGitConfigClient{}, dummyRemotesFn)
_, err := resolver.ResolvePullRequestRefs(baseRepo, "", "")
require.Error(t, err)
})
t.Run("when the default pr head has a repo, it is used for the refs", func(t *testing.T) {
t.Parallel()
// Push revision is the first thing checked for resolution,
// so nothing else needs to be stubbed.
repoResolvedFromPushRevisionClient := stubGitConfigClient{
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{
Remote: "origin",
Branch: "feature-branch",
}, nil),
}
resolver := NewPullRequestFindRefsResolver(
repoResolvedFromPushRevisionClient,
stubRemotes(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
)
refs, err := resolver.ResolvePullRequestRefs(baseRepo, "main", "feature-branch")
require.NoError(t, err)
expectedRefs := PRFindRefs{
qualifiedHeadRef: QualifiedHeadRef{
owner: o.Some("otherowner"),
branchName: "feature-branch",
},
baseRepo: baseRepo,
baseBranchName: o.Some("main"),
}
require.Equal(t, expectedRefs, refs)
})
t.Run("when the default pr head does not have a repo, we use the base repo for the head", func(t *testing.T) {
t.Parallel()
// All the values stubbed here result in being unable to resolve a default repo.
noRepoResolutionStubClient := stubGitConfigClient{
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")),
readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil),
pushDefaultFn: stubPushDefault("", nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
}
resolver := NewPullRequestFindRefsResolver(
noRepoResolutionStubClient,
stubRemotes(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
)
refs, err := resolver.ResolvePullRequestRefs(baseRepo, "main", "feature-branch")
require.NoError(t, err)
expectedRefs := PRFindRefs{
qualifiedHeadRef: QualifiedHeadRef{
owner: o.None[string](),
branchName: "feature-branch",
},
baseRepo: baseRepo,
baseBranchName: o.Some("main"),
}
require.Equal(t, expectedRefs, refs)
})
}
func TestTryDetermineDefaultPRHead(t *testing.T) {
t.Parallel()
baseRepo := ghrepo.New("owner", "repo")
baseRemote := ghContext.Remote{
Remote: &git.Remote{
Name: "upstream",
},
Repo: baseRepo,
}
forkRepo := ghrepo.New("otherowner", "repo-fork")
forkRemote := ghContext.Remote{
Remote: &git.Remote{
Name: "origin",
},
Repo: forkRepo,
}
forkRepoURL, err := url.Parse("https://github.com/otherowner/repo-fork.git")
require.NoError(t, err)
t.Run("when the push revision is set, use that", func(t *testing.T) {
t.Parallel()
repoResolvedFromPushRevisionClient := stubGitConfigClient{
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{
Remote: "origin",
Branch: "remote-feature-branch",
}, nil),
}
defaultPRHead, err := TryDetermineDefaultPRHead(
repoResolvedFromPushRevisionClient,
stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
"feature-branch",
)
require.NoError(t, err)
require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
require.Equal(t, "remote-feature-branch", defaultPRHead.BranchName)
})
t.Run("when the branch config push remote is set to a name, use that", func(t *testing.T) {
t.Parallel()
repoResolvedFromPushRemoteClient := stubGitConfigClient{
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")),
readBranchConfigFn: stubBranchConfig(git.BranchConfig{
PushRemoteName: "origin",
}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil),
}
defaultPRHead, err := TryDetermineDefaultPRHead(
repoResolvedFromPushRemoteClient,
stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
"feature-branch",
)
require.NoError(t, err)
require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
require.Equal(t, "feature-branch", defaultPRHead.BranchName)
})
t.Run("when the branch config push remote is set to a URL, use that", func(t *testing.T) {
t.Parallel()
repoResolvedFromPushRemoteClient := stubGitConfigClient{
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")),
readBranchConfigFn: stubBranchConfig(git.BranchConfig{
PushRemoteURL: forkRepoURL,
}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil),
}
defaultPRHead, err := TryDetermineDefaultPRHead(
repoResolvedFromPushRemoteClient,
dummyRemoteToRepoResolver(),
"feature-branch",
)
require.NoError(t, err)
require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
require.Equal(t, "feature-branch", defaultPRHead.BranchName)
})
t.Run("when a remote push default is set, use that", func(t *testing.T) {
t.Parallel()
repoResolvedFromPushRemoteClient := stubGitConfigClient{
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")),
readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil),
remotePushDefaultFn: stubRemotePushDefault("origin", nil),
}
defaultPRHead, err := TryDetermineDefaultPRHead(
repoResolvedFromPushRemoteClient,
stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
"feature-branch",
)
require.NoError(t, err)
require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
require.Equal(t, "feature-branch", defaultPRHead.BranchName)
})
t.Run("when the branch config remote is set to a name, use that", func(t *testing.T) {
t.Parallel()
repoResolvedFromPushRemoteClient := stubGitConfigClient{
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")),
readBranchConfigFn: stubBranchConfig(git.BranchConfig{
RemoteName: "origin",
}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
}
defaultPRHead, err := TryDetermineDefaultPRHead(
repoResolvedFromPushRemoteClient,
stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
"feature-branch",
)
require.NoError(t, err)
require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
require.Equal(t, "feature-branch", defaultPRHead.BranchName)
})
t.Run("when the branch config remote is set to a URL, use that", func(t *testing.T) {
t.Parallel()
repoResolvedFromPushRemoteClient := stubGitConfigClient{
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("no push revision")),
readBranchConfigFn: stubBranchConfig(git.BranchConfig{
RemoteURL: forkRepoURL,
}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultCurrent, nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
}
defaultPRHead, err := TryDetermineDefaultPRHead(
repoResolvedFromPushRemoteClient,
dummyRemoteToRepoResolver(),
"feature-branch",
)
require.NoError(t, err)
require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
require.Equal(t, "feature-branch", defaultPRHead.BranchName)
})
t.Run("when git didn't provide the necessary information, return none for the remote", func(t *testing.T) {
t.Parallel()
// All the values stubbed here result in being unable to resolve a default repo.
noRepoResolutionStubClient := stubGitConfigClient{
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")),
readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil),
pushDefaultFn: stubPushDefault("", nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
}
defaultPRHead, err := TryDetermineDefaultPRHead(
noRepoResolutionStubClient,
stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
"feature-branch",
)
require.NoError(t, err)
require.True(t, defaultPRHead.Repo.IsNone(), "expected repo to be none")
require.Equal(t, "feature-branch", defaultPRHead.BranchName)
})
t.Run("when the push default is tracking or upstream, use the merge ref", func(t *testing.T) {
t.Parallel()
testCases := []struct {
pushDefault git.PushDefault
}{
{pushDefault: git.PushDefaultTracking},
{pushDefault: git.PushDefaultUpstream},
}
for _, tc := range testCases {
t.Run(string(tc.pushDefault), func(t *testing.T) {
t.Parallel()
repoResolvedFromPushRemoteClient := stubGitConfigClient{
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")),
readBranchConfigFn: stubBranchConfig(git.BranchConfig{
PushRemoteName: "origin",
MergeRef: "main",
}, nil),
pushDefaultFn: stubPushDefault(tc.pushDefault, nil),
}
defaultPRHead, err := TryDetermineDefaultPRHead(
repoResolvedFromPushRemoteClient,
stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
"feature-branch",
)
require.NoError(t, err)
require.True(t, ghrepo.IsSame(defaultPRHead.Repo.Unwrap(), forkRepo), "expected repos to be the same")
require.Equal(t, "main", defaultPRHead.BranchName)
})
}
t.Run("but if the merge ref is empty, error", func(t *testing.T) {
t.Parallel()
repoResolvedFromPushRemoteClient := stubGitConfigClient{
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("test error")),
readBranchConfigFn: stubBranchConfig(git.BranchConfig{
PushRemoteName: "origin",
MergeRef: "", // intentionally empty
}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultUpstream, nil),
}
_, err := TryDetermineDefaultPRHead(
repoResolvedFromPushRemoteClient,
stubRemoteToRepoResolver(ghContext.Remotes{&baseRemote, &forkRemote}, nil),
"feature-branch",
)
require.Error(t, err)
})
})
}
func dummyRemotesFn() (ghContext.Remotes, error) {
panic("remotes fn not implemented")
}
func dummyRemoteToRepoResolver() remoteToRepoResolver {
return NewRemoteToRepoResolver(dummyRemotesFn)
}
func stubRemoteToRepoResolver(remotes ghContext.Remotes, err error) remoteToRepoResolver {
return NewRemoteToRepoResolver(func() (ghContext.Remotes, error) {
return remotes, err
})
}
func mustParseQualifiedHeadRef(ref string) QualifiedHeadRef {
parsed, err := ParseQualifiedHeadRef(ref)
if err != nil {
panic(err)
}
return parsed
}

View file

@ -13,11 +13,12 @@ import (
"time"
"github.com/cli/cli/v2/api"
remotes "github.com/cli/cli/v2/context"
ghContext "github.com/cli/cli/v2/context"
"github.com/cli/cli/v2/git"
fd "github.com/cli/cli/v2/internal/featuredetection"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmdutil"
o "github.com/cli/cli/v2/pkg/option"
"github.com/cli/cli/v2/pkg/set"
"github.com/shurcooL/githubv4"
"golang.org/x/sync/errgroup"
@ -32,16 +33,20 @@ type progressIndicator interface {
StopProgressIndicator()
}
type GitConfigClient interface {
ReadBranchConfig(ctx context.Context, branchName string) (git.BranchConfig, error)
PushDefault(ctx context.Context) (git.PushDefault, error)
RemotePushDefault(ctx context.Context) (string, error)
PushRevision(ctx context.Context, branchName string) (git.RemoteTrackingRef, error)
}
type finder struct {
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
baseRepoFn func() (ghrepo.Interface, error)
branchFn func() (string, error)
httpClient func() (*http.Client, error)
remotesFn func() (ghContext.Remotes, error)
gitConfigClient GitConfigClient
progress progressIndicator
baseRefRepo ghrepo.Interface
prNumber int
@ -56,23 +61,12 @@ func NewFinder(factory *cmdutil.Factory) PRFinder {
}
return &finder{
baseRepoFn: factory.BaseRepo,
branchFn: factory.Branch,
remotesFn: factory.Remotes,
httpClient: factory.HttpClient,
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)
},
baseRepoFn: factory.BaseRepo,
branchFn: factory.Branch,
httpClient: factory.HttpClient,
gitConfigClient: factory.GitClient,
remotesFn: factory.Remotes,
progress: factory.IOStreams,
}
}
@ -97,28 +91,6 @@ 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 {
@ -138,7 +110,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
f.baseRefRepo = repo
}
var prRefs PullRequestRefs
var prRefs PRFindRefs
if opts.Selector == "" {
// You must be in a git repo for this case to work
currentBranchName, err := f.branchFn()
@ -148,7 +120,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
f.branchName = currentBranchName
// Get the branch config for the current branchName
branchConfig, err := f.branchConfig(f.branchName)
branchConfig, err := f.gitConfigClient.ReadBranchConfig(context.Background(), f.branchName)
if err != nil {
return nil, nil, err
}
@ -162,30 +134,19 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
// 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)
prRefsResolver := NewPullRequestFindRefsResolver(
// We requested the branch config already, so let's cache that
CachedBranchConfigGitConfigClient{
CachedBranchConfig: branchConfig,
GitConfigClient: f.gitConfigClient,
},
f.remotesFn,
)
prRefs, err = prRefsResolver.ResolvePullRequestRefs(f.baseRefRepo, opts.BaseBranch, f.branchName)
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)
@ -200,11 +161,17 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
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{})
qualifiedHeadRef, err := ParseQualifiedHeadRef(f.branchName)
if err != nil {
return nil, nil, err
}
prRefs = PRFindRefs{
qualifiedHeadRef: qualifiedHeadRef,
baseRepo: f.baseRefRepo,
baseBranchName: o.SomeIfNonZero(opts.BaseBranch),
}
}
}
@ -255,7 +222,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
return pr, f.baseRefRepo, err
}
} else {
pr, err = findForBranch(httpClient, f.baseRefRepo, opts.BaseBranch, prRefs.GetPRHeadLabel(), opts.States, fields.ToSlice())
pr, err = findForRefs(httpClient, prRefs, opts.States, fields.ToSlice())
if err != nil {
return pr, f.baseRefRepo, err
}
@ -317,72 +284,6 @@ func (f *finder) parseURL(prURL string) (ghrepo.Interface, int, error) {
return repo, prNumber, nil
}
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,
}
// 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, ", "))
}
// 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/")
}
// 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 != "" {
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
}
}
// 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 prRefs, nil
}
func findByNumber(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.PullRequest, error) {
type response struct {
Repository struct {
@ -413,7 +314,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, headBranchWithOwnerIfFork string, stateFilters, fields []string) (*api.PullRequest, error) {
func findForRefs(httpClient *http.Client, prRefs PRFindRefs, stateFilters, fields []string) (*api.PullRequest, error) {
type response struct {
Repository struct {
PullRequests struct {
@ -440,21 +341,16 @@ func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, h
}
}`, api.PullRequestGraphQL(fieldSet.ToSlice()))
branchWithoutOwner := headBranchWithOwnerIfFork
if idx := strings.Index(headBranchWithOwnerIfFork, ":"); idx >= 0 {
branchWithoutOwner = headBranchWithOwnerIfFork[idx+1:]
}
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"headRefName": branchWithoutOwner,
"owner": prRefs.BaseRepo().RepoOwner(),
"repo": prRefs.BaseRepo().RepoName(),
"headRefName": prRefs.UnqualifiedHeadRef(),
"states": stateFilters,
}
var resp response
client := api.NewClientFromHTTP(httpClient)
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
err := client.GraphQL(prRefs.BaseRepo().RepoHost(), query, variables, &resp)
if err != nil {
return nil, err
}
@ -465,17 +361,15 @@ func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, h
})
for _, pr := range prs {
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 != headBranchWithOwnerIfFork
if headBranchMatches && baseBranchEmptyOrMatches && isNotClosedOrMergedWhenHeadIsDefault {
isNotClosedOrMergedWhenHeadIsDefault := pr.State == "OPEN" || resp.Repository.DefaultBranchRef.Name != prRefs.QualifiedHeadRef()
if prRefs.Matches(pr.BaseRefName, pr.HeadLabel()) && isNotClosedOrMergedWhenHeadIsDefault {
return &pr, nil
}
}
return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", headBranchWithOwnerIfFork)}
return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", prRefs.QualifiedHeadRef())}
}
func preloadPrReviews(httpClient *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error {

View file

@ -1,46 +1,41 @@
package shared
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"testing"
"github.com/cli/cli/v2/context"
ghContext "github.com/cli/cli/v2/context"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type args struct {
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
baseRepoFn func() (ghrepo.Interface, error)
branchFn func() (string, error)
gitConfigClient stubGitConfigClient
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{
remoteOrigin := ghContext.Remote{
Remote: &git.Remote{
Name: "origin",
FetchURL: originOwnerUrl,
},
Repo: ghrepo.New("ORIGINOWNER", "REPO"),
}
remoteOther := context.Remote{
remoteOther := ghContext.Remote{
Remote: &git.Remote{
Name: "other",
FetchURL: originOwnerUrl,
@ -52,7 +47,7 @@ func TestFind(t *testing.T) {
if err != nil {
t.Fatal(err)
}
remoteUpstream := context.Remote{
remoteUpstream := ghContext.Remote{
Remote: &git.Remote{
Name: "upstream",
FetchURL: upstreamOwnerUrl,
@ -77,7 +72,6 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -112,12 +106,14 @@ func TestFind(t *testing.T) {
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),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{
PushRemoteName: remoteOrigin.Remote.Name,
}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -147,9 +143,11 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
},
},
wantErr: true,
},
@ -170,9 +168,11 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
},
},
httpStub: nil,
wantPR: 13,
@ -187,9 +187,11 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -210,9 +212,11 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -236,15 +240,17 @@ func TestFind(t *testing.T) {
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,
}),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, &git.GitError{
Stderr: "fatal: branchConfig error",
ExitCode: 128,
}),
pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil),
remotePushDefaultFn: stubRemotePushDefault("", &git.GitError{
Stderr: "fatal: remotePushDefault error",
ExitCode: 128,
}),
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -265,10 +271,12 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
parsePushRevision: stubParsedPushRevision("", nil),
remotePushDefault: stubRemotePushDefault("", nil),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil),
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
remotePushDefaultFn: stubRemotePushDefault("", nil),
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -309,10 +317,12 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
parsePushRevision: stubParsedPushRevision("", nil),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -352,10 +362,12 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
parsePushRevision: stubParsedPushRevision("", nil),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -387,10 +399,12 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
parsePushRevision: stubParsedPushRevision("", nil),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -436,13 +450,15 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{
MergeRef: "refs/heads/blue-upstream-berries",
PushRemoteName: "upstream",
}, nil),
pushDefault: stubPushDefault("upstream", nil),
remotePushDefault: stubRemotePushDefault("", nil),
parsePushRevision: stubParsedPushRevision("", nil),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{
MergeRef: "refs/heads/blue-upstream-berries",
PushRemoteName: "upstream",
}, nil),
pushDefaultFn: stubPushDefault("upstream", nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -476,13 +492,15 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", 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),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{
MergeRef: "refs/heads/blue-upstream-berries",
PushRemoteURL: remoteUpstream.Remote.FetchURL,
}, nil),
pushDefaultFn: stubPushDefault("upstream", nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{}, errors.New("testErr")),
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -512,10 +530,12 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
parsePushRevision: stubParsedPushRevision("other/blueberries", nil),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
pushRevisionFn: stubPushRevision(git.RemoteTrackingRef{Remote: "other", Branch: "blueberries"}, nil),
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -547,9 +567,11 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{
MergeRef: "refs/pull/13/head",
}, nil),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{
MergeRef: "refs/pull/13/head",
}, nil),
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -572,11 +594,13 @@ func TestFind(t *testing.T) {
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: stubBranchConfig(git.BranchConfig{
MergeRef: "refs/pull/13/head",
}, nil),
pushDefault: stubPushDefault("simple", nil),
remotePushDefault: stubRemotePushDefault("", nil),
gitConfigClient: stubGitConfigClient{
readBranchConfigFn: stubBranchConfig(git.BranchConfig{
MergeRef: "refs/pull/13/head",
}, nil),
pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil),
remotePushDefaultFn: stubRemotePushDefault("", nil),
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
@ -588,32 +612,32 @@ func TestFind(t *testing.T) {
r.Register(
httpmock.GraphQL(`query PullRequestProjectItems\b`),
httpmock.GraphQLQuery(`{
"data": {
"repository": {
"pullRequest": {
"projectItems": {
"nodes": [
{
"id": "PVTI_lADOB-vozM4AVk16zgK6U50",
"project": {
"id": "PVT_kwDOB-vozM4AVk16",
"title": "Test Project"
},
"status": {
"optionId": "47fc9ee4",
"name": "In Progress"
}
}
],
"pageInfo": {
"hasNextPage": false,
"endCursor": "MQ"
}
}
}
}
}
}`,
"data": {
"repository": {
"pullRequest": {
"projectItems": {
"nodes": [
{
"id": "PVTI_lADOB-vozM4AVk16zgK6U50",
"project": {
"id": "PVT_kwDOB-vozM4AVk16",
"title": "Test Project"
},
"status": {
"optionId": "47fc9ee4",
"name": "In Progress"
}
}
],
"pageInfo": {
"hasNextPage": false,
"endCursor": "MQ"
}
}
}
}
}
}`,
func(query string, inputs map[string]interface{}) {
require.Equal(t, float64(13), inputs["number"])
require.Equal(t, "OWNER", inputs["owner"])
@ -637,13 +661,10 @@ 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,
pushDefault: tt.args.pushDefault,
remotePushDefault: tt.args.remotePushDefault,
parsePushRevision: tt.args.parsePushRevision,
remotesFn: stubRemotes(context.Remotes{
baseRepoFn: tt.args.baseRepoFn,
branchFn: tt.args.branchFn,
gitConfigClient: tt.args.gitConfigClient,
remotesFn: stubRemotes(ghContext.Remotes{
&remoteOrigin,
&remoteOther,
&remoteUpstream,
@ -680,319 +701,14 @@ func TestFind(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
branchConfig git.BranchConfig
pushDefault string
parsedPushRevision string
remotePushDefault string
currentBranchName string
baseRefRepo ghrepo.Interface
rems context.Remotes
wantPRRefs PullRequestRefs
wantErr error
}{
{
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,
},
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) {
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)
}
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())
})
}
}
func stubBranchConfig(branchConfig git.BranchConfig, err error) func(string) (git.BranchConfig, error) {
return func(branch string) (git.BranchConfig, error) {
func stubBranchConfig(branchConfig git.BranchConfig, err error) func(context.Context, string) (git.BranchConfig, error) {
return func(_ context.Context, branch string) (git.BranchConfig, error) {
return branchConfig, err
}
}
func stubRemotes(remotes context.Remotes, err error) func() (context.Remotes, error) {
return func() (context.Remotes, error) {
func stubRemotes(remotes ghContext.Remotes, err error) func() (ghContext.Remotes, error) {
return func() (ghContext.Remotes, error) {
return remotes, err
}
}
@ -1003,20 +719,55 @@ func stubBaseRepoFn(baseRepo ghrepo.Interface, err error) func() (ghrepo.Interfa
}
}
func stubPushDefault(pushDefault string, err error) func() (string, error) {
return func() (string, error) {
func stubPushDefault(pushDefault git.PushDefault, err error) func(context.Context) (git.PushDefault, error) {
return func(_ context.Context) (git.PushDefault, error) {
return pushDefault, err
}
}
func stubRemotePushDefault(remotePushDefault string, err error) func() (string, error) {
return func() (string, error) {
func stubRemotePushDefault(remotePushDefault string, err error) func(context.Context) (string, error) {
return func(_ context.Context) (string, error) {
return remotePushDefault, err
}
}
func stubParsedPushRevision(parsedPushRevision string, err error) func(string) (string, error) {
return func(_ string) (string, error) {
func stubPushRevision(parsedPushRevision git.RemoteTrackingRef, err error) func(context.Context, string) (git.RemoteTrackingRef, error) {
return func(_ context.Context, _ string) (git.RemoteTrackingRef, error) {
return parsedPushRevision, err
}
}
type stubGitConfigClient struct {
readBranchConfigFn func(ctx context.Context, branchName string) (git.BranchConfig, error)
pushDefaultFn func(ctx context.Context) (git.PushDefault, error)
remotePushDefaultFn func(ctx context.Context) (string, error)
pushRevisionFn func(ctx context.Context, branchName string) (git.RemoteTrackingRef, error)
}
func (s stubGitConfigClient) ReadBranchConfig(ctx context.Context, branchName string) (git.BranchConfig, error) {
if s.readBranchConfigFn == nil {
panic("unexpected call to ReadBranchConfig")
}
return s.readBranchConfigFn(ctx, branchName)
}
func (s stubGitConfigClient) PushDefault(ctx context.Context) (git.PushDefault, error) {
if s.pushDefaultFn == nil {
panic("unexpected call to PushDefault")
}
return s.pushDefaultFn(ctx)
}
func (s stubGitConfigClient) RemotePushDefault(ctx context.Context) (string, error) {
if s.remotePushDefaultFn == nil {
panic("unexpected call to RemotePushDefault")
}
return s.remotePushDefaultFn(ctx)
}
func (s stubGitConfigClient) PushRevision(ctx context.Context, branchName string) (git.RemoteTrackingRef, error) {
if s.pushRevisionFn == nil {
panic("unexpected call to PushRevision")
}
return s.pushRevisionFn(ctx, branchName)
}

View file

@ -0,0 +1,18 @@
package shared
import (
"context"
"github.com/cli/cli/v2/git"
)
var _ GitConfigClient = &CachedBranchConfigGitConfigClient{}
type CachedBranchConfigGitConfigClient struct {
CachedBranchConfig git.BranchConfig
GitConfigClient
}
func (c CachedBranchConfigGitConfigClient) ReadBranchConfig(ctx context.Context, branchName string) (git.BranchConfig, error) {
return c.CachedBranchConfig, nil
}

View file

@ -102,43 +102,34 @@ func statusRun(opts *StatusOptions) error {
return fmt.Errorf("could not query for pull request for current branch: %w", err)
}
branchConfig, err := opts.GitClient.ReadBranchConfig(ctx, currentBranchName)
if err != nil {
return 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 {
currentPRNumber, _ = strconv.Atoi(m[1])
}
if currentPRNumber == 0 {
remotes, err := opts.Remotes()
if !errors.Is(err, git.ErrNotOnAnyBranch) {
branchConfig, err := opts.GitClient.ReadBranchConfig(ctx, currentBranchName)
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
// 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])
}
pushDefault, err := opts.GitClient.PushDefault(ctx)
if err != nil {
return err
}
if currentPRNumber == 0 {
prRefsResolver := shared.NewPullRequestFindRefsResolver(
// We requested the branch config already, so let's cache that
shared.CachedBranchConfigGitConfigClient{
CachedBranchConfig: branchConfig,
GitConfigClient: opts.GitClient,
},
opts.Remotes,
)
prRefs, err := shared.ParsePRRefs(currentBranchName, branchConfig, parsedPushRevision, pushDefault, remotePushDefault, baseRefRepo, remotes)
if err != nil {
return err
}
currentHeadRefBranchName = prRefs.BranchName
}
prRefs, err := prRefsResolver.ResolvePullRequestRefs(baseRefRepo, "", currentBranchName)
if err != nil {
return err
}
if err != nil {
return fmt.Errorf("could not query for pull request for current branch: %w", err)
currentHeadRefBranchName = prRefs.QualifiedHeadRef()
}
}
}
@ -316,6 +307,6 @@ func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
}
remaining := totalCount - len(prs)
if remaining > 0 {
fmt.Fprintf(w, cs.Gray(" And %d more\n"), remaining)
fmt.Fprintln(w, cs.Mutedf(" And %d more", remaining))
}
}

View file

@ -98,10 +98,10 @@ func TestPRStatus(t *testing.T) {
// stub successful git commands
rs, cleanup := run.Stub()
defer cleanup(t)
rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
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, "")
rs.Register(`git config push.default`, 1, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -133,8 +133,8 @@ func TestPRStatus_reviewsAndChecks(t *testing.T) {
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, "")
rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
rs.Register(`git config push.default`, 1, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -166,8 +166,8 @@ func TestPRStatus_reviewsAndChecksWithStatesByCount(t *testing.T) {
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, "")
rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
rs.Register(`git config push.default`, 1, "")
output, err := runCommandWithDetector(http, "blueberries", true, "", &fd.EnabledDetectorMock{})
if err != nil {
@ -198,8 +198,8 @@ func TestPRStatus_currentBranch_showTheMostRecentPR(t *testing.T) {
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, "")
rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
rs.Register(`git config push.default`, 1, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -234,8 +234,8 @@ func TestPRStatus_currentBranch_defaultBranch(t *testing.T) {
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, "")
rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
rs.Register(`git config push.default`, 1, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -276,8 +276,8 @@ func TestPRStatus_currentBranch_Closed(t *testing.T) {
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, "")
rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
rs.Register(`git config push.default`, 1, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -301,8 +301,8 @@ func TestPRStatus_currentBranch_Closed_defaultBranch(t *testing.T) {
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, "")
rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
rs.Register(`git config push.default`, 1, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -326,8 +326,8 @@ func TestPRStatus_currentBranch_Merged(t *testing.T) {
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, "")
rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
rs.Register(`git config push.default`, 1, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -351,8 +351,8 @@ func TestPRStatus_currentBranch_Merged_defaultBranch(t *testing.T) {
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, "")
rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
rs.Register(`git config push.default`, 1, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -376,8 +376,8 @@ func TestPRStatus_blankSlate(t *testing.T) {
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, "")
rs.Register(`git rev-parse --symbolic-full-name blueberries@{push}`, 128, "")
rs.Register(`git config push.default`, 1, "")
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
@ -432,14 +432,6 @@ func TestPRStatus_detachedHead(t *testing.T) {
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestStatus\b`), httpmock.StringResponse(`{"data": {}}`))
// stub successful git command
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 {
t.Errorf("error running command `pr status`: %v", err)

View file

@ -260,7 +260,7 @@ func printHumanPrPreview(opts *ViewOptions, baseRepo ghrepo.Interface, pr *api.P
var md string
var err error
if pr.Body == "" {
md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided"))
md = fmt.Sprintf("\n %s\n\n", cs.Muted("No description provided"))
} else {
md, err = markdown.Render(pr.Body,
markdown.WithTheme(opts.IO.TerminalTheme()),
@ -282,7 +282,7 @@ func printHumanPrPreview(opts *ViewOptions, baseRepo ghrepo.Interface, pr *api.P
}
// Footer
fmt.Fprintf(out, cs.Gray("View this pull request on GitHub: %s\n"), pr.URL)
fmt.Fprintf(out, cs.Muted("View this pull request on GitHub: %s\n"), pr.URL)
return nil
}
@ -423,7 +423,7 @@ func prLabelList(pr api.PullRequest, cs *iostreams.ColorScheme) string {
labelNames := make([]string, 0, len(pr.Labels.Nodes))
for _, label := range pr.Labels.Nodes {
labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name))
labelNames = append(labelNames, cs.Label(label.Color, label.Name))
}
list := strings.Join(labelNames, ", ")

View file

@ -129,19 +129,19 @@ func viewRun(opts *ViewOptions) error {
}
func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error {
iofmt := io.ColorScheme()
cs := io.ColorScheme()
w := io.Out
fmt.Fprintf(w, "%s\n", iofmt.Bold(release.TagName))
fmt.Fprintf(w, "%s\n", cs.Bold(release.TagName))
if release.IsDraft {
fmt.Fprintf(w, "%s • ", iofmt.Red("Draft"))
fmt.Fprintf(w, "%s • ", cs.Red("Draft"))
} else if release.IsPrerelease {
fmt.Fprintf(w, "%s • ", iofmt.Yellow("Pre-release"))
fmt.Fprintf(w, "%s • ", cs.Yellow("Pre-release"))
}
if release.IsDraft {
fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt))))
fmt.Fprintln(w, cs.Mutedf("%s created this %s", release.Author.Login, text.FuzzyAgo(time.Now(), release.CreatedAt)))
} else {
fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt))))
fmt.Fprintln(w, cs.Mutedf("%s released this %s", release.Author.Login, text.FuzzyAgo(time.Now(), *release.PublishedAt)))
}
renderedDescription, err := markdown.Render(release.Body,
@ -153,7 +153,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error {
fmt.Fprintln(w, renderedDescription)
if len(release.Assets) > 0 {
fmt.Fprintf(w, "%s\n", iofmt.Bold("Assets"))
fmt.Fprintln(w, cs.Bold("Assets"))
//nolint:staticcheck // SA1019: Showing NAME|SIZE headers adds nothing to table.
table := tableprinter.New(io, tableprinter.NoHeader)
for _, a := range release.Assets {
@ -168,7 +168,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error {
fmt.Fprint(w, "\n")
}
fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("View on GitHub: %s", release.URL)))
fmt.Fprintln(w, cs.Mutedf("View on GitHub: %s", release.URL))
return nil
}

View file

@ -262,10 +262,15 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
func createRun(opts *CreateOptions) error {
if opts.Interactive {
cfg, err := opts.Config()
if err != nil {
return err
}
host, _ := cfg.Authentication().DefaultHost()
answer, err := opts.Prompter.Select("What would you like to do?", "", []string{
"Create a new repository on GitHub from scratch",
"Create a new repository on GitHub from a template repository",
"Push an existing local repository to GitHub",
fmt.Sprintf("Create a new repository on %s from scratch", host),
fmt.Sprintf("Create a new repository on %s from a template repository", host),
fmt.Sprintf("Push an existing local repository to %s", host),
})
if err != nil {
return err
@ -323,7 +328,9 @@ func createFromScratch(opts *CreateOptions) error {
if idx := strings.IndexRune(opts.Name, '/'); idx > 0 {
targetRepo = opts.Name[0:idx+1] + shared.NormalizeRepoName(opts.Name[idx+1:])
}
confirmed, err := opts.Prompter.Confirm(fmt.Sprintf(`This will create "%s" as a %s repository on GitHub. Continue?`, targetRepo, strings.ToLower(opts.Visibility)), true)
confirmed, err := opts.Prompter.Confirm(
fmt.Sprintf(`This will create "%s" as a %s repository on %s. Continue?`, targetRepo, strings.ToLower(opts.Visibility), host),
true)
if err != nil {
return err
} else if !confirmed {
@ -392,9 +399,10 @@ func createFromScratch(opts *CreateOptions) error {
isTTY := opts.IO.IsStdoutTTY()
if isTTY {
fmt.Fprintf(opts.IO.Out,
"%s Created repository %s on GitHub\n %s\n",
"%s Created repository %s on %s\n %s\n",
cs.SuccessIconWithColor(cs.Green),
ghrepo.FullName(repo),
host,
repo.URL)
} else {
fmt.Fprintln(opts.IO.Out, repo.URL)
@ -482,7 +490,9 @@ func createFromTemplate(opts *CreateOptions) error {
if idx := strings.IndexRune(opts.Name, '/'); idx > 0 {
targetRepo = opts.Name[0:idx+1] + shared.NormalizeRepoName(opts.Name[idx+1:])
}
confirmed, err := opts.Prompter.Confirm(fmt.Sprintf(`This will create "%s" as a %s repository on GitHub. Continue?`, targetRepo, strings.ToLower(opts.Visibility)), true)
confirmed, err := opts.Prompter.Confirm(
fmt.Sprintf(`This will create "%s" as a %s repository on %s. Continue?`, targetRepo, strings.ToLower(opts.Visibility), host),
true)
if err != nil {
return err
} else if !confirmed {
@ -496,9 +506,10 @@ func createFromTemplate(opts *CreateOptions) error {
cs := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.Out,
"%s Created repository %s on GitHub\n %s\n",
"%s Created repository %s on %s\n %s\n",
cs.SuccessIconWithColor(cs.Green),
ghrepo.FullName(repo),
host,
repo.URL)
opts.Clone, err = opts.Prompter.Confirm("Clone the new repository locally?", true)
@ -622,9 +633,10 @@ func createFromLocal(opts *CreateOptions) error {
if isTTY {
fmt.Fprintf(stdout,
"%s Created repository %s on GitHub\n %s\n",
"%s Created repository %s on %s\n %s\n",
cs.SuccessIconWithColor(cs.Green),
ghrepo.FullName(repo),
host,
repo.URL)
} else {
fmt.Fprintln(stdout, repo.URL)

View file

@ -10,6 +10,7 @@ import (
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/gh"
ghmock "github.com/cli/cli/v2/internal/gh/mock"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/cli/v2/pkg/cmdutil"
@ -201,7 +202,7 @@ func Test_createRun(t *testing.T) {
name: "interactive create from scratch with gitignore and license",
opts: &CreateOptions{Interactive: true},
tty: true,
wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n",
wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n",
promptStubs: func(p *prompter.PrompterMock) {
p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
switch message {
@ -211,7 +212,7 @@ func Test_createRun(t *testing.T) {
return true, nil
case "Would you like to add a license?":
return true, nil
case `This will create "REPO" as a private repository on GitHub. Continue?`:
case `This will create "REPO" as a private repository on github.com. Continue?`:
return defaultValue, nil
case "Clone the new repository locally?":
return defaultValue, nil
@ -232,7 +233,7 @@ func Test_createRun(t *testing.T) {
p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
switch message {
case "What would you like to do?":
return prompter.IndexFor(options, "Create a new repository on GitHub from scratch")
return prompter.IndexFor(options, "Create a new repository on github.com from scratch")
case "Visibility":
return prompter.IndexFor(options, "Private")
case "Choose a license":
@ -267,7 +268,7 @@ func Test_createRun(t *testing.T) {
name: "interactive create from scratch but with prompted owner",
opts: &CreateOptions{Interactive: true},
tty: true,
wantStdout: "✓ Created repository org1/REPO on GitHub\n https://github.com/org1/REPO\n",
wantStdout: "✓ Created repository org1/REPO on github.com\n https://github.com/org1/REPO\n",
promptStubs: func(p *prompter.PrompterMock) {
p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
switch message {
@ -277,7 +278,7 @@ func Test_createRun(t *testing.T) {
return false, nil
case "Would you like to add a license?":
return false, nil
case `This will create "org1/REPO" as a private repository on GitHub. Continue?`:
case `This will create "org1/REPO" as a private repository on github.com. Continue?`:
return true, nil
case "Clone the new repository locally?":
return false, nil
@ -300,7 +301,7 @@ func Test_createRun(t *testing.T) {
case "Repository owner":
return prompter.IndexFor(options, "org1")
case "What would you like to do?":
return prompter.IndexFor(options, "Create a new repository on GitHub from scratch")
return prompter.IndexFor(options, "Create a new repository on github.com from scratch")
case "Visibility":
return prompter.IndexFor(options, "Private")
default:
@ -345,7 +346,7 @@ func Test_createRun(t *testing.T) {
return false, nil
case "Would you like to add a license?":
return false, nil
case `This will create "REPO" as a private repository on GitHub. Continue?`:
case `This will create "REPO" as a private repository on github.com. Continue?`:
return false, nil
default:
return false, fmt.Errorf("unexpected confirm prompt: %s", message)
@ -364,7 +365,7 @@ func Test_createRun(t *testing.T) {
p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
switch message {
case "What would you like to do?":
return prompter.IndexFor(options, "Create a new repository on GitHub from scratch")
return prompter.IndexFor(options, "Create a new repository on github.com from scratch")
case "Visibility":
return prompter.IndexFor(options, "Private")
default:
@ -409,7 +410,7 @@ func Test_createRun(t *testing.T) {
p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
switch message {
case "What would you like to do?":
return prompter.IndexFor(options, "Push an existing local repository to GitHub")
return prompter.IndexFor(options, "Push an existing local repository to github.com")
case "Visibility":
return prompter.IndexFor(options, "Private")
default:
@ -441,7 +442,7 @@ func Test_createRun(t *testing.T) {
cs.Register(`git -C . rev-parse --git-dir`, 0, ".git")
cs.Register(`git -C . rev-parse HEAD`, 0, "commithash")
},
wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n",
wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n",
},
{
name: "interactive with existing bare repository public and push",
@ -475,7 +476,7 @@ func Test_createRun(t *testing.T) {
p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
switch message {
case "What would you like to do?":
return prompter.IndexFor(options, "Push an existing local repository to GitHub")
return prompter.IndexFor(options, "Push an existing local repository to github.com")
case "Visibility":
return prompter.IndexFor(options, "Private")
default:
@ -509,7 +510,7 @@ func Test_createRun(t *testing.T) {
cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "")
cs.Register(`git -C . push origin --mirror`, 0, "")
},
wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Mirrored all refs to https://github.com/OWNER/REPO.git\n",
wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Mirrored all refs to https://github.com/OWNER/REPO.git\n",
},
{
name: "interactive with existing repository public add remote and push",
@ -543,7 +544,7 @@ func Test_createRun(t *testing.T) {
p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
switch message {
case "What would you like to do?":
return prompter.IndexFor(options, "Push an existing local repository to GitHub")
return prompter.IndexFor(options, "Push an existing local repository to github.com")
case "Visibility":
return prompter.IndexFor(options, "Private")
default:
@ -577,7 +578,7 @@ func Test_createRun(t *testing.T) {
cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "")
cs.Register(`git -C . push --set-upstream origin HEAD`, 0, "")
},
wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Pushed commits to https://github.com/OWNER/REPO.git\n",
wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Pushed commits to https://github.com/OWNER/REPO.git\n",
},
{
name: "interactive create from a template repository",
@ -586,7 +587,7 @@ func Test_createRun(t *testing.T) {
promptStubs: func(p *prompter.PrompterMock) {
p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
switch message {
case `This will create "OWNER/REPO" as a private repository on GitHub. Continue?`:
case `This will create "OWNER/REPO" as a private repository on github.com. Continue?`:
return defaultValue, nil
case "Clone the new repository locally?":
return defaultValue, nil
@ -611,7 +612,7 @@ func Test_createRun(t *testing.T) {
case "Choose a template repository":
return prompter.IndexFor(options, "REPO")
case "What would you like to do?":
return prompter.IndexFor(options, "Create a new repository on GitHub from a template repository")
return prompter.IndexFor(options, "Create a new repository on github.com from a template repository")
case "Visibility":
return prompter.IndexFor(options, "Private")
default:
@ -654,7 +655,7 @@ func Test_createRun(t *testing.T) {
execStubs: func(cs *run.CommandStubber) {
cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 0, "")
},
wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n",
wantStdout: "✓ Created repository OWNER/REPO on github.com\n https://github.com/OWNER/REPO\n",
},
{
name: "interactive create from template repo but there are no template repos",
@ -680,7 +681,7 @@ func Test_createRun(t *testing.T) {
p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
switch message {
case "What would you like to do?":
return prompter.IndexFor(options, "Create a new repository on GitHub from a template repository")
return prompter.IndexFor(options, "Create a new repository on github.com from a template repository")
case "Visibility":
return prompter.IndexFor(options, "Private")
default:
@ -950,6 +951,88 @@ func Test_createRun(t *testing.T) {
},
wantStdout: "https://github.com/OWNER/REPO\n",
},
{
name: "interactive create from scratch with host override",
opts: &CreateOptions{
Interactive: true,
Config: func() (gh.Config, error) {
cfg := &ghmock.ConfigMock{
AuthenticationFunc: func() gh.AuthConfig {
authCfg := &config.AuthConfig{}
authCfg.SetHosts([]string{"example.com"})
authCfg.SetDefaultHost("example.com", "GH_HOST")
return authCfg
},
}
return cfg, nil
},
},
tty: true,
promptStubs: func(p *prompter.PrompterMock) {
p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
switch message {
case "Would you like to add a README file?":
return false, nil
case "Would you like to add a .gitignore?":
return false, nil
case "Would you like to add a license?":
return false, nil
case `This will create "REPO" as a private repository on example.com. Continue?`:
return defaultValue, nil
case "Clone the new repository locally?":
return false, nil
default:
return false, fmt.Errorf("unexpected confirm prompt: %s", message)
}
}
p.InputFunc = func(message, defaultValue string) (string, error) {
switch message {
case "Repository name":
return "REPO", nil
case "Description":
return "my new repo", nil
default:
return "", fmt.Errorf("unexpected input prompt: %s", message)
}
}
p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
switch message {
case "What would you like to do?":
return prompter.IndexFor(options, "Create a new repository on example.com from scratch")
case "Visibility":
return prompter.IndexFor(options, "Private")
case "Choose a license":
return prompter.IndexFor(options, "GNU Lesser General Public License v3.0")
case "Choose a .gitignore template":
return prompter.IndexFor(options, "Go")
default:
return 0, fmt.Errorf("unexpected select prompt: %s", message)
}
}
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"someuser","organizations":{"nodes": []}}}}`))
reg.Register(
httpmock.GraphQL(`mutation RepositoryCreate\b`),
httpmock.StringResponse(`
{
"data": {
"createRepository": {
"repository": {
"id": "REPOID",
"name": "REPO",
"owner": {"login":"OWNER"},
"url": "https://example.com/OWNER/REPO"
}
}
}
}`),
)
},
wantStdout: "✓ Created repository OWNER/REPO on example.com\n https://example.com/OWNER/REPO\n",
},
}
for _, tt := range tests {
prompterMock := &prompter.PrompterMock{}
@ -965,8 +1048,11 @@ func Test_createRun(t *testing.T) {
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
tt.opts.Config = func() (gh.Config, error) {
return config.NewBlankConfig(), nil
if tt.opts.Config == nil {
tt.opts.Config = func() (gh.Config, error) {
return config.NewBlankConfig(), nil
}
}
tt.opts.GitClient = &git.Client{

View file

@ -119,9 +119,9 @@ func renderLicense(license *api.License, opts *ViewOptions) error {
cs := opts.IO.ColorScheme()
var out strings.Builder
if opts.IO.IsStdoutTTY() {
out.WriteString(fmt.Sprintf("\n%s\n", cs.Gray(license.Description)))
out.WriteString(fmt.Sprintf("\n%s\n", cs.Grayf("To implement: %s", license.Implementation)))
out.WriteString(fmt.Sprintf("\n%s\n\n", cs.Grayf("For more information, see: %s", license.HTMLURL)))
out.WriteString(fmt.Sprintf("\n%s\n", cs.Muted(license.Description)))
out.WriteString(fmt.Sprintf("\n%s\n", cs.Mutedf("To implement: %s", license.Implementation)))
out.WriteString(fmt.Sprintf("\n%s\n\n", cs.Mutedf("For more information, see: %s", license.HTMLURL)))
}
out.WriteString(license.Body)
_, err := opts.IO.Out.Write([]byte(out.String()))

View file

@ -181,7 +181,7 @@ func viewRun(opts *ViewOptions) error {
var readmeContent string
if readme == nil {
readmeContent = cs.Gray("This repository does not have a README")
readmeContent = cs.Muted("This repository does not have a README")
} else if isMarkdownFile(readme.Filename) {
var err error
readmeContent, err = markdown.Render(readme.Content,
@ -197,7 +197,7 @@ func viewRun(opts *ViewOptions) error {
description := repo.Description
if description == "" {
description = cs.Gray("No description provided")
description = cs.Muted("No description provided")
}
repoData := struct {
@ -209,7 +209,7 @@ func viewRun(opts *ViewOptions) error {
FullName: cs.Bold(fullName),
Description: description,
Readme: readmeContent,
View: cs.Gray(fmt.Sprintf("View this repository on GitHub: %s", openURL)),
View: cs.Mutedf("View this repository on GitHub: %s", openURL),
}
return tmpl.Execute(stdout, repoData)

View file

@ -87,7 +87,7 @@ func isRootCmd(command *cobra.Command) bool {
return command != nil && !command.HasParent()
}
func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, args []string) {
func rootHelpFunc(f *cmdutil.Factory, command *cobra.Command, _ []string) {
flags := command.Flags()
if isRootCmd(command) {

View file

@ -81,6 +81,9 @@ var HelpTopics = []helpTopic{
%[1]sCLICOLOR_FORCE%[1]s: set to a value other than %[1]s0%[1]s to keep ANSI colors in output
even when the output is piped.
%[1]sGH_COLOR_LABELS%[1]s: set to any value to display labels using their RGB hex color codes in terminals that
support truecolor.
%[1]sGH_FORCE_TTY%[1]s: set to any value to force terminal-style output even when the output is
redirected. When the value is a number, it is interpreted as the number of columns
available in the viewport. When the value is a percentage, it will be applied against
@ -108,6 +111,12 @@ var HelpTopics = []helpTopic{
%[1]sGH_MDWIDTH%[1]s: default maximum width for markdown render wrapping. The max width of lines
wrapped on the terminal will be taken as the lesser of the terminal width, this value, or 120 if
not specified. This value is used, for example, with %[1]spr view%[1]s subcommand.
%[1]sGH_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are
more compatible with speech synthesis and braille screen readers.
%[1]sGH_SPINNER_DISABLED%[1]s: set to a truthy value to replace the spinner animation with
a textual progress indicator.
`, "`"),
},
{

View file

@ -52,7 +52,8 @@ func RenderAnnotations(cs *iostreams.ColorScheme, annotations []Annotation) stri
for _, a := range annotations {
lines = append(lines, fmt.Sprintf("%s %s", AnnotationSymbol(cs, a), a.Message))
lines = append(lines, cs.Grayf("%s: %s#%d\n", a.JobName, a.Path, a.StartLine))
// Following newline is essential for spacing between annotations
lines = append(lines, cs.Mutedf("%s: %s#%d\n", a.JobName, a.Path, a.StartLine))
}
return strings.Join(lines, "\n")

View file

@ -230,6 +230,8 @@ type Job struct {
CompletedAt time.Time `json:"completed_at"`
URL string `json:"html_url"`
RunID int64 `json:"run_id"`
Log *zip.File
}
type Step struct {
@ -239,7 +241,8 @@ type Step struct {
Number int
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
Log *zip.File
Log *zip.File
}
type Steps []Step
@ -575,7 +578,7 @@ func Symbol(cs *iostreams.ColorScheme, status Status, conclusion Conclusion) (st
case Success:
return cs.SuccessIconWithColor(noColor), cs.Green
case Skipped, Neutral:
return "-", cs.Gray
return "-", cs.Muted
default:
return cs.FailureIconWithColor(noColor), cs.Red
}

View file

@ -104,6 +104,60 @@ var SuccessfulJob Job = Job{
},
}
// Note that this run *has* steps, but in the ZIP archive the step logs are not
// included.
var SuccessfulJobWithoutStepLogs Job = Job{
ID: 11,
Status: Completed,
Conclusion: Success,
Name: "cool job with no step logs",
StartedAt: TestRunStartTime,
CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
URL: "https://github.com/jobs/11",
RunID: 3,
Steps: []Step{
{
Name: "fob the barz",
Status: Completed,
Conclusion: Success,
Number: 1,
},
{
Name: "barz the fob",
Status: Completed,
Conclusion: Success,
Number: 2,
},
},
}
// Note that this run *has* steps, but in the ZIP archive the step logs are not
// included.
var LegacySuccessfulJobWithoutStepLogs Job = Job{
ID: 12,
Status: Completed,
Conclusion: Success,
Name: "legacy cool job with no step logs",
StartedAt: TestRunStartTime,
CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
URL: "https://github.com/jobs/12",
RunID: 3,
Steps: []Step{
{
Name: "fob the barz",
Status: Completed,
Conclusion: Success,
Number: 1,
},
{
Name: "barz the fob",
Status: Completed,
Conclusion: Success,
Number: 2,
},
},
}
var FailedJob Job = Job{
ID: 20,
Status: Completed,
@ -129,6 +183,60 @@ var FailedJob Job = Job{
},
}
// Note that this run *has* steps, but in the ZIP archive the step logs are not
// included.
var FailedJobWithoutStepLogs Job = Job{
ID: 21,
Status: Completed,
Conclusion: Failure,
Name: "sad job with no step logs",
StartedAt: TestRunStartTime,
CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
URL: "https://github.com/jobs/21",
RunID: 1234,
Steps: []Step{
{
Name: "barf the quux",
Status: Completed,
Conclusion: Success,
Number: 1,
},
{
Name: "quux the barf",
Status: Completed,
Conclusion: Failure,
Number: 2,
},
},
}
// Note that this run *has* steps, but in the ZIP archive the step logs are not
// included.
var LegacyFailedJobWithoutStepLogs Job = Job{
ID: 22,
Status: Completed,
Conclusion: Failure,
Name: "legacy sad job with no step logs",
StartedAt: TestRunStartTime,
CompletedAt: TestRunStartTime.Add(time.Minute*4 + time.Second*34),
URL: "https://github.com/jobs/22",
RunID: 1234,
Steps: []Step{
{
Name: "barf the quux",
Status: Completed,
Conclusion: Success,
Number: 1,
},
{
Name: "quux the barf",
Status: Completed,
Conclusion: Failure,
Number: 2,
},
},
}
var SuccessfulJobAnnotations []Annotation = []Annotation{
{
JobName: "cool job",

View file

@ -118,6 +118,10 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
This command does not support authenticating via fine grained PATs
as it is not currently possible to create a PAT with the %[1]schecks:read%[1]s permission.
Due to platform limitations, %[1]sgh%[1]s may not always be able to associate log lines with a
particular step in a job. In this case, the step name in the log output will be replaced with
%[1]sUNKNOWN STEP%[1]s.
`, "`"),
Args: cobra.MaximumNArgs(1),
Example: heredoc.Doc(`
@ -397,7 +401,7 @@ func runView(opts *ViewOptions) error {
for _, a := range artifacts {
expiredBadge := ""
if a.Expired {
expiredBadge = cs.Gray(" (expired)")
expiredBadge = cs.Muted(" (expired)")
}
fmt.Fprintf(out, "%s%s\n", a.Name, expiredBadge)
}
@ -411,7 +415,7 @@ func runView(opts *ViewOptions) error {
} else {
fmt.Fprintf(out, "For more information about a job, try: gh run view --job=<job-id>\n")
}
fmt.Fprintf(out, cs.Gray("View this run on GitHub: %s\n"), run.URL)
fmt.Fprintln(out, cs.Mutedf("View this run on GitHub: %s", run.URL))
if opts.ExitStatus && shared.IsFailureState(run.Conclusion) {
return cmdutil.SilentError
@ -423,7 +427,7 @@ func runView(opts *ViewOptions) error {
} else {
fmt.Fprintf(out, "To see the full job log, try: gh run view --log --job=%d\n", selectedJob.ID)
}
fmt.Fprintf(out, cs.Gray("View this run on GitHub: %s\n"), run.URL)
fmt.Fprintln(out, cs.Mutedf("View this run on GitHub: %s", run.URL))
if opts.ExitStatus && shared.IsFailureState(selectedJob.Conclusion) {
return cmdutil.SilentError
@ -533,7 +537,7 @@ func promptForJob(prompter shared.Prompter, cs *iostreams.ColorScheme, jobs []sh
const JOB_NAME_MAX_LENGTH = 90
func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
func getJobNameForLogFilename(name string) string {
// As described in https://github.com/cli/cli/issues/5011#issuecomment-1570713070, there are a number of steps
// the server can take when producing the downloaded zip file that can result in a mismatch between the job name
// and the filename in the zip including:
@ -545,10 +549,31 @@ func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
// * Strip `/` which occur when composite action job names are constructed of the form `<JOB_NAME`> / <ACTION_NAME>`
// * Truncate long job names
//
sanitizedJobName := strings.ReplaceAll(job.Name, "/", "")
sanitizedJobName := strings.ReplaceAll(name, "/", "")
sanitizedJobName = strings.ReplaceAll(sanitizedJobName, ":", "")
sanitizedJobName = truncateAsUTF16(sanitizedJobName, JOB_NAME_MAX_LENGTH)
re := fmt.Sprintf(`^%s\/%d_.*\.txt`, regexp.QuoteMeta(sanitizedJobName), step.Number)
return sanitizedJobName
}
// A job run log file is a top-level .txt file whose name starts with an ordinal
// number; e.g., "0_jobname.txt".
func jobLogFilenameRegexp(job shared.Job) *regexp.Regexp {
sanitizedJobName := getJobNameForLogFilename(job.Name)
re := fmt.Sprintf(`^\d+_%s\.txt$`, regexp.QuoteMeta(sanitizedJobName))
return regexp.MustCompile(re)
}
// A legacy job run log file is a top-level .txt file whose name starts with a
// negative number which is the ID of the run; e.g., "-2147483648_jobname.txt".
func legacyJobLogFilenameRegexp(job shared.Job) *regexp.Regexp {
sanitizedJobName := getJobNameForLogFilename(job.Name)
re := fmt.Sprintf(`^-\d+_%s\.txt$`, regexp.QuoteMeta(sanitizedJobName))
return regexp.MustCompile(re)
}
func stepLogFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
sanitizedJobName := getJobNameForLogFilename(job.Name)
re := fmt.Sprintf(`^%s\/%d_.*\.txt$`, regexp.QuoteMeta(sanitizedJobName), step.Number)
return regexp.MustCompile(re)
}
@ -627,29 +652,60 @@ func truncateAsUTF16(str string, max int) string {
// │ ├── 2_anotherstepname.txt
// │ ├── 3_stepstepname.txt
// │ └── 4_laststepname.txt
// └── jobname2/
// ├── 1_stepname.txt
// └── 2_somestepname.txt
// ├── jobname2/
// | ├── 1_stepname.txt
// | └── 2_somestepname.txt
// ├── 0_jobname1.txt
// ├── 1_jobname2.txt
// └── -9999999999_jobname3.txt
//
// It iterates through the list of jobs and tries to find the matching
// log in the zip file. If the matching log is found it is attached
// to the job.
//
// The top-level .txt files include the logs for an entire job run. Note that
// the prefixed number is either:
// - An ordinal and cannot be mapped to the corresponding job's ID.
// - A negative integer which is the ID of the job in the old Actions service.
// The service right now tries to get logs and use an ordinal in a loop.
// However, if it doesn't get the logs, it falls back to an old service
// where the ID can apparently be negative.
func attachRunLog(rlz *zip.Reader, jobs []shared.Job) {
for i, job := range jobs {
// As a highest priority, we try to use the step logs first. We have seen zips that surprisingly contain
// step logs, normal job logs and legacy job logs. In this case, both job logs would be ignored. We have
// never seen a zip containing both job logs and no step logs, however, it may be possible. In that case
// let's prioritise the normal log over the legacy one.
jobLog := matchFileInZIPArchive(rlz, jobLogFilenameRegexp(job))
if jobLog == nil {
jobLog = matchFileInZIPArchive(rlz, legacyJobLogFilenameRegexp(job))
}
jobs[i].Log = jobLog
for j, step := range job.Steps {
re := logFilenameRegexp(job, step)
for _, file := range rlz.File {
if re.MatchString(file.Name) {
jobs[i].Steps[j].Log = file
break
}
}
jobs[i].Steps[j].Log = matchFileInZIPArchive(rlz, stepLogFilenameRegexp(job, step))
}
}
}
func matchFileInZIPArchive(zr *zip.Reader, re *regexp.Regexp) *zip.File {
for _, file := range zr.File {
if re.MatchString(file.Name) {
return file
}
}
return nil
}
func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error {
for _, job := range jobs {
// To display a run log, we first try to compile it from individual step
// logs, because this way we can prepend lines with the corresponding
// step name. However, at the time of writing, logs are sometimes being
// served by a service that doesnt include the step logs (none of them),
// in which case we fall back to print the entire job run log.
var hasStepLogs bool
steps := job.Steps
sort.Sort(steps)
for _, step := range steps {
@ -659,18 +715,49 @@ func displayRunLog(w io.Writer, jobs []shared.Job, failed bool) error {
if step.Log == nil {
continue
}
hasStepLogs = true
prefix := fmt.Sprintf("%s\t%s\t", job.Name, step.Name)
f, err := step.Log.Open()
if err != nil {
if err := printZIPFile(w, step.Log, prefix); err != nil {
return err
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text())
}
f.Close()
}
if hasStepLogs {
continue
}
if failed && !shared.IsFailureState(job.Conclusion) {
continue
}
if job.Log == nil {
continue
}
// Here, we fall back to the job run log, which means we do not know
// the step name of lines. However, we want to keep the same line
// formatting to avoid breaking any code or script that rely on the
// tab-delimited formatting. So, an unknown-step placeholder is used
// instead of the actual step name.
prefix := fmt.Sprintf("%s\tUNKNOWN STEP\t", job.Name)
if err := printZIPFile(w, job.Log, prefix); err != nil {
return err
}
}
return nil
}
func printZIPFile(w io.Writer, file *zip.File, prefix string) error {
f, err := file.Open()
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text())
}
return nil
}

View file

@ -990,6 +990,619 @@ func TestViewRun(t *testing.T) {
},
wantOut: quuxTheBarfLogOutput,
},
{
name: "interactive with log, with no step logs available (#10551)",
tty: true,
opts: &ViewOptions{
Prompt: true,
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.REST("GET", "runs/3/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJobWithoutStepLogs,
shared.FailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
httpmock.JSONResponse(workflowShared.WorkflowsPayload{
Workflows: []workflowShared.Workflow{
shared.TestWorkflow,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool job with no step logs")
})
},
wantOut: coolJobRunWithNoStepLogsLogOutput,
},
{
name: "noninteractive with log, with no step logs available (#10551)",
opts: &ViewOptions{
JobID: "11",
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/11"),
httpmock.JSONResponse(shared.SuccessfulJobWithoutStepLogs))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: coolJobRunWithNoStepLogsLogOutput,
},
{
name: "interactive with log-failed, with no step logs available (#10551)",
tty: true,
opts: &ViewOptions{
Prompt: true,
LogFailed: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJobWithoutStepLogs,
shared.FailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
httpmock.JSONResponse(workflowShared.WorkflowsPayload{
Workflows: []workflowShared.Workflow{
shared.TestWorkflow,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return 4, nil
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "X sad job with no step logs")
})
},
wantOut: sadJobRunWithNoStepLogsLogOutput,
},
{
name: "noninteractive with log-failed, with no step logs available (#10551)",
opts: &ViewOptions{
JobID: "21",
LogFailed: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/21"),
httpmock.JSONResponse(shared.FailedJobWithoutStepLogs))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: sadJobRunWithNoStepLogsLogOutput,
},
{
name: "interactive with run log, with no step logs available (#10551)",
tty: true,
opts: &ViewOptions{
Prompt: true,
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.REST("GET", "runs/3/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJobWithoutStepLogs,
shared.FailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
httpmock.JSONResponse(workflowShared.WorkflowsPayload{
Workflows: []workflowShared.Workflow{
shared.TestWorkflow,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "View all jobs in this run")
})
},
wantOut: expectedRunLogOutputWithNoSteps,
},
{
name: "noninteractive with run log, with no step logs available (#10551)",
opts: &ViewOptions{
RunID: "3",
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.REST("GET", "runs/3/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJobWithoutStepLogs,
shared.FailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: expectedRunLogOutputWithNoSteps,
},
{
name: "interactive with run log-failed, with no step logs available (#10551)",
tty: true,
opts: &ViewOptions{
Prompt: true,
LogFailed: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJobWithoutStepLogs,
shared.FailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
httpmock.JSONResponse(workflowShared.WorkflowsPayload{
Workflows: []workflowShared.Workflow{
shared.TestWorkflow,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return 4, nil
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ cool job with no step logs", "X sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "View all jobs in this run")
})
},
wantOut: sadJobRunWithNoStepLogsLogOutput,
},
{
name: "noninteractive with run log-failed, with no step logs available (#10551)",
opts: &ViewOptions{
RunID: "1234",
LogFailed: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.SuccessfulJobWithoutStepLogs,
shared.FailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: sadJobRunWithNoStepLogsLogOutput,
},
{
name: "interactive with log, legacy service data, with no step logs available",
tty: true,
opts: &ViewOptions{
Prompt: true,
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.REST("GET", "runs/3/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.LegacySuccessfulJobWithoutStepLogs,
shared.LegacyFailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
httpmock.JSONResponse(workflowShared.WorkflowsPayload{
Workflows: []workflowShared.Workflow{
shared.TestWorkflow,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ legacy cool job with no step logs")
})
},
wantOut: legacyCoolJobRunWithNoStepLogsLogOutput,
},
{
name: "noninteractive with log, legacy service data, with no step logs available",
opts: &ViewOptions{
JobID: "12",
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/12"),
httpmock.JSONResponse(shared.LegacySuccessfulJobWithoutStepLogs))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: legacyCoolJobRunWithNoStepLogsLogOutput,
},
{
name: "interactive with log-failed, legacy service data, with no step logs available (#10551)",
tty: true,
opts: &ViewOptions{
Prompt: true,
LogFailed: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.LegacySuccessfulJobWithoutStepLogs,
shared.LegacyFailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
httpmock.JSONResponse(workflowShared.WorkflowsPayload{
Workflows: []workflowShared.Workflow{
shared.TestWorkflow,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return 4, nil
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "X legacy sad job with no step logs")
})
},
wantOut: legacySadJobRunWithNoStepLogsLogOutput,
},
{
name: "noninteractive with log-failed, legacy service data, with no step logs available (#10551)",
opts: &ViewOptions{
JobID: "22",
LogFailed: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/22"),
httpmock.JSONResponse(shared.LegacyFailedJobWithoutStepLogs))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: legacySadJobRunWithNoStepLogsLogOutput,
},
{
name: "interactive with run log, legacy service data, with no step logs available (#10551)",
tty: true,
opts: &ViewOptions{
Prompt: true,
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.REST("GET", "runs/3/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.LegacySuccessfulJobWithoutStepLogs,
shared.LegacyFailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
httpmock.JSONResponse(workflowShared.WorkflowsPayload{
Workflows: []workflowShared.Workflow{
shared.TestWorkflow,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "View all jobs in this run")
})
},
wantOut: expectedLegacyRunLogOutputWithNoSteps,
},
{
name: "noninteractive with run log, legacy service data, with no step logs available (#10551)",
opts: &ViewOptions{
RunID: "3",
Log: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3"),
httpmock.JSONResponse(shared.SuccessfulRun))
reg.Register(
httpmock.REST("GET", "runs/3/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.LegacySuccessfulJobWithoutStepLogs,
shared.LegacyFailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/3/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: expectedLegacyRunLogOutputWithNoSteps,
},
{
name: "interactive with run log-failed, legacy service data, with no step logs available (#10551)",
tty: true,
opts: &ViewOptions{
Prompt: true,
LogFailed: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
httpmock.JSONResponse(shared.RunsPayload{
WorkflowRuns: shared.TestRuns,
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.LegacySuccessfulJobWithoutStepLogs,
shared.LegacyFailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
httpmock.JSONResponse(workflowShared.WorkflowsPayload{
Workflows: []workflowShared.Workflow{
shared.TestWorkflow,
},
}))
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return 4, nil
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ legacy cool job with no step logs", "X legacy sad job with no step logs"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "View all jobs in this run")
})
},
wantOut: legacySadJobRunWithNoStepLogsLogOutput,
},
{
name: "noninteractive with run log-failed, legacy service data, with no step logs available (#10551)",
opts: &ViewOptions{
RunID: "1234",
LogFailed: true,
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
httpmock.JSONResponse(shared.FailedRun))
reg.Register(
httpmock.REST("GET", "runs/1234/jobs"),
httpmock.JSONResponse(shared.JobsPayload{
Jobs: []shared.Job{
shared.LegacySuccessfulJobWithoutStepLogs,
shared.LegacyFailedJobWithoutStepLogs,
},
}))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234/logs"),
httpmock.FileResponse("./fixtures/run_log.zip"))
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
httpmock.JSONResponse(shared.TestWorkflow))
},
wantOut: legacySadJobRunWithNoStepLogsLogOutput,
},
{
name: "run log but run is not done",
tty: true,
@ -1419,14 +2032,24 @@ func TestViewRun(t *testing.T) {
// ├── sad job/
// │ ├── 1_barf the quux.txt
// │ └── 2_quux the barf.txt
// └── ad job/
// └── 1_barf the quux.txt
// ├── ad job/
// | └── 1_barf the quux.txt
// ├── 0_cool job.txt
// ├── 1_sad job.txt
// ├── 2_cool job with no step logs.txt
// ├── 3_sad job with no step logs.txt
// ├── -9999999999_legacy cool job with no step logs.txt
// ├── -9999999999_legacy sad job with no step logs.txt
// ├── 4_cool job with both legacy and new logs.txt
// └── -9999999999_cool job with both legacy and new logs.txt
func Test_attachRunLog(t *testing.T) {
tests := []struct {
name string
job shared.Job
wantMatch bool
wantFilename string
name string
job shared.Job
wantJobMatch bool
wantJobFilename string
wantStepMatch bool
wantStepFilename string
}{
{
name: "matching job name and step number 1",
@ -1437,8 +2060,10 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantFilename: "cool job/1_fob the barz.txt",
wantJobMatch: true,
wantJobFilename: "0_cool job.txt",
wantStepMatch: true,
wantStepFilename: "cool job/1_fob the barz.txt",
},
{
name: "matching job name and step number 2",
@ -1449,8 +2074,10 @@ func Test_attachRunLog(t *testing.T) {
Number: 2,
}},
},
wantMatch: true,
wantFilename: "cool job/2_barz the fob.txt",
wantJobMatch: true,
wantJobFilename: "0_cool job.txt",
wantStepMatch: true,
wantStepFilename: "cool job/2_barz the fob.txt",
},
{
name: "matching job name and step number and mismatch step name",
@ -1461,8 +2088,10 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantFilename: "cool job/1_fob the barz.txt",
wantJobMatch: true,
wantJobFilename: "0_cool job.txt",
wantStepMatch: true,
wantStepFilename: "cool job/1_fob the barz.txt",
},
{
name: "matching job name and mismatch step number",
@ -1473,7 +2102,62 @@ func Test_attachRunLog(t *testing.T) {
Number: 3,
}},
},
wantMatch: false,
wantJobMatch: true,
wantJobFilename: "0_cool job.txt",
wantStepMatch: false,
},
{
name: "matching job name with no step logs",
job: shared.Job{
Name: "cool job with no step logs",
Steps: []shared.Step{{
Name: "fob the barz",
Number: 1,
}},
},
wantJobMatch: true,
wantJobFilename: "2_cool job with no step logs.txt",
wantStepMatch: false,
},
{
name: "matching job name with no step data",
job: shared.Job{
Name: "cool job with no step logs",
},
wantJobMatch: true,
wantJobFilename: "2_cool job with no step logs.txt",
wantStepMatch: false,
},
{
name: "matching job name with legacy filename and no step logs",
job: shared.Job{
Name: "legacy cool job with no step logs",
Steps: []shared.Step{{
Name: "fob the barz",
Number: 1,
}},
},
wantJobMatch: true,
wantJobFilename: "-9999999999_legacy cool job with no step logs.txt",
wantStepMatch: false,
},
{
name: "matching job name with legacy filename and no step data",
job: shared.Job{
Name: "legacy cool job with no step logs",
},
wantJobMatch: true,
wantJobFilename: "-9999999999_legacy cool job with no step logs.txt",
wantStepMatch: false,
},
{
name: "matching job name with both normal and legacy filename",
job: shared.Job{
Name: "cool job with both legacy and new logs",
},
wantJobMatch: true,
wantJobFilename: "4_cool job with both legacy and new logs.txt",
wantStepMatch: false,
},
{
name: "one job name is a suffix of another",
@ -1484,8 +2168,8 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantFilename: "ad job/1_barf the quux.txt",
wantStepMatch: true,
wantStepFilename: "ad job/1_barf the quux.txt",
},
{
name: "escape metacharacters in job name",
@ -1496,7 +2180,8 @@ func Test_attachRunLog(t *testing.T) {
Number: 0,
}},
},
wantMatch: false,
wantJobMatch: false,
wantStepMatch: false,
},
{
name: "mismatching job name",
@ -1507,7 +2192,8 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: false,
wantJobMatch: false,
wantStepMatch: false,
},
{
name: "job name with forward slash matches dir with slash removed",
@ -1518,9 +2204,10 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantJobMatch: false,
wantStepMatch: true,
// not the double space in the dir name, as the slash has been removed
wantFilename: "cool job with slash/1_fob the barz.txt",
wantStepFilename: "cool job with slash/1_fob the barz.txt",
},
{
name: "job name with colon matches dir with colon removed",
@ -1531,8 +2218,9 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantFilename: "cool job with colon/1_fob the barz.txt",
wantJobMatch: false,
wantStepMatch: true,
wantStepFilename: "cool job with colon/1_fob the barz.txt",
},
{
name: "Job name with really long name (over the ZIP limit)",
@ -1543,8 +2231,9 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantFilename: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_Long Name Job.txt",
wantJobMatch: false,
wantStepMatch: true,
wantStepFilename: "thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnineteenchars_thisisnine/1_Long Name Job.txt",
},
{
name: "Job name that would be truncated by the C# server to split a grapheme",
@ -1555,8 +2244,9 @@ func Test_attachRunLog(t *testing.T) {
Number: 1,
}},
},
wantMatch: true,
wantFilename: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅<F09F9885>/1_Emoji Job.txt",
wantJobMatch: false,
wantStepMatch: true,
wantStepFilename: "Emoji Test 😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅😅<F09F9885>/1_Emoji Job.txt",
},
}
@ -1566,17 +2256,27 @@ func Test_attachRunLog(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jobs := []shared.Job{tt.job}
attachRunLog(&run_log_zip_reader.Reader, []shared.Job{tt.job})
attachRunLog(&run_log_zip_reader.Reader, jobs)
t.Logf("Job details: ")
for _, step := range tt.job.Steps {
log := step.Log
logPresent := log != nil
require.Equal(t, tt.wantMatch, logPresent, "log not present")
if logPresent {
require.Equal(t, tt.wantFilename, log.Name, "Filename mismatch")
job := jobs[0]
jobLog := job.Log
jobLogPresent := jobLog != nil
require.Equal(t, tt.wantJobMatch, jobLogPresent, "job log not present")
if jobLogPresent {
require.Equal(t, tt.wantJobFilename, jobLog.Name, "job log filename mismatch")
}
for _, step := range job.Steps {
stepLog := step.Log
stepLogPresent := stepLog != nil
require.Equal(t, tt.wantStepMatch, stepLogPresent, "step log not present")
if stepLogPresent {
require.Equal(t, tt.wantStepFilename, stepLog.Name, "step log filename mismatch")
}
}
})
@ -1607,9 +2307,35 @@ sad job quux the barf log line 2
sad job quux the barf log line 3
`)
var coolJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
cool job with no step logs UNKNOWN STEP log line 1
cool job with no step logs UNKNOWN STEP log line 2
cool job with no step logs UNKNOWN STEP log line 3
`)
var legacyCoolJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
legacy cool job with no step logs UNKNOWN STEP log line 1
legacy cool job with no step logs UNKNOWN STEP log line 2
legacy cool job with no step logs UNKNOWN STEP log line 3
`)
var sadJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
sad job with no step logs UNKNOWN STEP log line 1
sad job with no step logs UNKNOWN STEP log line 2
sad job with no step logs UNKNOWN STEP log line 3
`)
var legacySadJobRunWithNoStepLogsLogOutput = heredoc.Doc(`
legacy sad job with no step logs UNKNOWN STEP log line 1
legacy sad job with no step logs UNKNOWN STEP log line 2
legacy sad job with no step logs UNKNOWN STEP log line 3
`)
var coolJobRunLogOutput = fmt.Sprintf("%s%s", fobTheBarzLogOutput, barfTheFobLogOutput)
var sadJobRunLogOutput = fmt.Sprintf("%s%s", barfTheQuuxLogOutput, quuxTheBarfLogOutput)
var expectedRunLogOutput = fmt.Sprintf("%s%s", coolJobRunLogOutput, sadJobRunLogOutput)
var expectedRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", coolJobRunWithNoStepLogsLogOutput, sadJobRunWithNoStepLogsLogOutput)
var expectedLegacyRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", legacyCoolJobRunWithNoStepLogsLogOutput, legacySadJobRunWithNoStepLogsLogOutput)
func TestRunLog(t *testing.T) {
t.Run("when the cache dir doesn't exist, exists return false", func(t *testing.T) {

View file

@ -161,7 +161,7 @@ func displayResults(io *iostreams.IOStreams, now time.Time, results search.Commi
tp.AddField(commit.Sha)
tp.AddField(text.RemoveExcessiveWhitespace(commit.Info.Message))
tp.AddField(commit.Author.Login)
tp.AddTimeField(now, commit.Info.Author.Date, cs.Gray)
tp.AddTimeField(now, commit.Info.Author.Date, cs.Muted)
tp.EndRow()
}
if io.IsStdoutTTY() {

View file

@ -171,14 +171,14 @@ func displayResults(io *iostreams.IOStreams, now time.Time, results search.Repos
tags = append(tags, "archived")
}
info := strings.Join(tags, ", ")
infoColor := cs.Gray
infoColor := cs.Muted
if repo.IsPrivate {
infoColor = cs.Yellow
}
tp.AddField(repo.FullName, tableprinter.WithColor(cs.Bold))
tp.AddField(text.RemoveExcessiveWhitespace(repo.Description))
tp.AddField(info, tableprinter.WithColor(infoColor))
tp.AddTimeField(now, repo.UpdatedAt, cs.Gray)
tp.AddTimeField(now, repo.UpdatedAt, cs.Muted)
tp.EndRow()
}
if io.IsStdoutTTY() {

View file

@ -132,7 +132,7 @@ func displayIssueResults(io *iostreams.IOStreams, now time.Time, et EntityType,
}
tp.AddField(text.RemoveExcessiveWhitespace(issue.Title))
tp.AddField(listIssueLabels(&issue, cs, tp.IsTTY()))
tp.AddTimeField(now, issue.UpdatedAt, cs.Gray)
tp.AddTimeField(now, issue.UpdatedAt, cs.Muted)
tp.EndRow()
}
@ -158,7 +158,7 @@ func listIssueLabels(issue *search.Issue, cs *iostreams.ColorScheme, colorize bo
labelNames := make([]string, 0, len(issue.Labels))
for _, label := range issue.Labels {
if colorize {
labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name))
labelNames = append(labelNames, cs.Label(label.Color, label.Name))
} else {
labelNames = append(labelNames, label.Name)
}

View file

@ -740,7 +740,7 @@ func statusRun(opts *StatusOptions) error {
errs := sg.authErrors.ToSlice()
sort.Strings(errs)
for _, msg := range errs {
fmt.Fprintln(out, cs.Gray(fmt.Sprintf("warning: %s", msg)))
fmt.Fprintln(out, cs.Mutedf("warning: %s", msg))
}
}

View file

@ -175,7 +175,7 @@ func viewWorkflowContent(opts *ViewOptions, client *api.Client, repo ghrepo.Inte
out := opts.IO.Out
fileName := workflow.Base()
fmt.Fprintf(out, "%s - %s\n", cs.Bold(workflow.Name), cs.Gray(fileName))
fmt.Fprintf(out, "%s - %s\n", cs.Bold(workflow.Name), cs.Muted(fileName))
fmt.Fprintf(out, "ID: %s", cs.Cyanf("%d", workflow.ID))
codeBlock := fmt.Sprintf("```yaml\n%s\n```", yaml)

View file

@ -3,6 +3,8 @@ package httpmock
import (
"fmt"
"net/http"
"runtime/debug"
"strings"
"sync"
"testing"
@ -23,6 +25,7 @@ type Registry struct {
func (r *Registry) Register(m Matcher, resp Responder) {
r.stubs = append(r.stubs, &Stub{
Stack: string(debug.Stack()),
Matcher: m,
Responder: resp,
})
@ -46,17 +49,24 @@ type Testing interface {
}
func (r *Registry) Verify(t Testing) {
n := 0
var unmatchedStubStacks []string
for _, s := range r.stubs {
if !s.matched && !s.exclude {
n++
unmatchedStubStacks = append(unmatchedStubStacks, s.Stack)
}
}
if n > 0 {
if len(unmatchedStubStacks) > 0 {
t.Helper()
// NOTE: stubs offer no useful reflection, so we can't print details
stacks := strings.Builder{}
for i, stack := range unmatchedStubStacks {
stacks.WriteString(fmt.Sprintf("Stub %d:\n", i+1))
stacks.WriteString(fmt.Sprintf("\t%s", stack))
if stack != unmatchedStubStacks[len(unmatchedStubStacks)-1] {
stacks.WriteString("\n")
}
}
// about dead stubs and what they were trying to match
t.Errorf("%d unmatched HTTP stubs", n)
t.Errorf("%d HTTP stubs unmatched, stacks:\n%s", len(unmatchedStubStacks), stacks.String())
}
}
@ -84,7 +94,7 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
if stub == nil {
r.mu.Unlock()
return nil, fmt.Errorf("no registered stubs matched %v", req)
return nil, fmt.Errorf("no registered HTTP stubs matched %v", req)
}
r.Requests = append(r.Requests, req)

View file

@ -15,6 +15,7 @@ type Matcher func(req *http.Request) bool
type Responder func(req *http.Request) (*http.Response, error)
type Stub struct {
Stack string
matched bool
Matcher Matcher
Responder Responder

View file

@ -41,33 +41,24 @@ var (
}
)
// NewColorScheme initializes color logic based on provided terminal capabilities.
// Logic dealing with terminal theme detected, such as whether color is enabled, 8-bit color supported, true color supported,
// and terminal theme detected.
func NewColorScheme(enabled, is256enabled, trueColor, accessibleColors bool, theme string) *ColorScheme {
return &ColorScheme{
enabled: enabled,
is256enabled: is256enabled,
hasTrueColor: trueColor,
accessibleColors: accessibleColors,
theme: theme,
}
}
// ColorScheme controls how text is colored based upon terminal capabilities and user preferences.
type ColorScheme struct {
enabled bool
is256enabled bool
hasTrueColor bool
accessibleColors bool
theme string
}
func (c *ColorScheme) Enabled() bool {
return c.enabled
// Enabled is whether color is used at all.
Enabled bool
// EightBitColor is whether the terminal supports 8-bit, 256 colors.
EightBitColor bool
// TrueColor is whether the terminal supports 24-bit, 16 million colors.
TrueColor bool
// Accessible is whether colors must be base 16 colors that users can customize in terminal preferences.
Accessible bool
// ColorLabels is whether labels are colored based on their truecolor RGB hex color.
ColorLabels bool
// Theme is the terminal background color theme used to contextually color text for light, dark, or none at all.
Theme string
}
func (c *ColorScheme) Bold(t string) string {
if !c.enabled {
if !c.Enabled {
return t
}
return bold(t)
@ -79,16 +70,16 @@ func (c *ColorScheme) Boldf(t string, args ...interface{}) string {
func (c *ColorScheme) Muted(t string) string {
// Fallback to previous logic if accessible colors preview is disabled.
if !c.accessibleColors {
if !c.Accessible {
return c.Gray(t)
}
// Muted text is only stylized if color is enabled.
if !c.enabled {
if !c.Enabled {
return t
}
switch c.theme {
switch c.Theme {
case LightTheme:
return lightThemeMuted(t)
case DarkTheme:
@ -103,7 +94,7 @@ func (c *ColorScheme) Mutedf(t string, args ...interface{}) string {
}
func (c *ColorScheme) Red(t string) string {
if !c.enabled {
if !c.Enabled {
return t
}
return red(t)
@ -114,7 +105,7 @@ func (c *ColorScheme) Redf(t string, args ...interface{}) string {
}
func (c *ColorScheme) Yellow(t string) string {
if !c.enabled {
if !c.Enabled {
return t
}
return yellow(t)
@ -125,7 +116,7 @@ func (c *ColorScheme) Yellowf(t string, args ...interface{}) string {
}
func (c *ColorScheme) Green(t string) string {
if !c.enabled {
if !c.Enabled {
return t
}
return green(t)
@ -136,30 +127,30 @@ func (c *ColorScheme) Greenf(t string, args ...interface{}) string {
}
func (c *ColorScheme) GreenBold(t string) string {
if !c.enabled {
if !c.Enabled {
return t
}
return greenBold(t)
}
// Use Muted instead for thematically contrasting color.
// Deprecated: Use Muted instead for thematically contrasting color.
func (c *ColorScheme) Gray(t string) string {
if !c.enabled {
if !c.Enabled {
return t
}
if c.is256enabled {
if c.EightBitColor {
return gray256(t)
}
return gray(t)
}
// Use Mutedf instead for thematically contrasting color.
// Deprecated: Use Mutedf instead for thematically contrasting color.
func (c *ColorScheme) Grayf(t string, args ...interface{}) string {
return c.Gray(fmt.Sprintf(t, args...))
}
func (c *ColorScheme) Magenta(t string) string {
if !c.enabled {
if !c.Enabled {
return t
}
return magenta(t)
@ -170,7 +161,7 @@ func (c *ColorScheme) Magentaf(t string, args ...interface{}) string {
}
func (c *ColorScheme) Cyan(t string) string {
if !c.enabled {
if !c.Enabled {
return t
}
return cyan(t)
@ -181,14 +172,14 @@ func (c *ColorScheme) Cyanf(t string, args ...interface{}) string {
}
func (c *ColorScheme) CyanBold(t string) string {
if !c.enabled {
if !c.Enabled {
return t
}
return cyanBold(t)
}
func (c *ColorScheme) Blue(t string) string {
if !c.enabled {
if !c.Enabled {
return t
}
return blue(t)
@ -219,7 +210,7 @@ func (c *ColorScheme) FailureIconWithColor(colo func(string) string) string {
}
func (c *ColorScheme) HighlightStart() string {
if !c.enabled {
if !c.Enabled {
return ""
}
@ -227,7 +218,7 @@ func (c *ColorScheme) HighlightStart() string {
}
func (c *ColorScheme) Highlight(t string) string {
if !c.enabled {
if !c.Enabled {
return t
}
@ -235,7 +226,7 @@ func (c *ColorScheme) Highlight(t string) string {
}
func (c *ColorScheme) Reset() string {
if !c.enabled {
if !c.Enabled {
return ""
}
@ -255,7 +246,7 @@ func (c *ColorScheme) ColorFromString(s string) func(string) string {
case "green":
fn = c.Green
case "gray":
fn = c.Gray
fn = c.Muted
case "magenta":
fn = c.Magenta
case "cyan":
@ -271,17 +262,9 @@ func (c *ColorScheme) ColorFromString(s string) func(string) string {
return fn
}
// ColorFromRGB returns a function suitable for TablePrinter.AddField
// that calls HexToRGB, coloring text if supported by the terminal.
func (c *ColorScheme) ColorFromRGB(hex string) func(string) string {
return func(s string) string {
return c.HexToRGB(hex, s)
}
}
// HexToRGB uses the given hex to color x if supported by the terminal.
func (c *ColorScheme) HexToRGB(hex string, x string) string {
if !c.enabled || !c.hasTrueColor || len(hex) != 6 {
// Label stylizes text based on label's RGB hex color.
func (c *ColorScheme) Label(hex string, x string) string {
if !c.Enabled || !c.TrueColor || !c.ColorLabels || len(hex) != 6 {
return x
}
@ -293,11 +276,11 @@ func (c *ColorScheme) HexToRGB(hex string, x string) string {
func (c *ColorScheme) TableHeader(t string) string {
// Table headers are only stylized if color is enabled including underline modifier.
if !c.enabled {
if !c.Enabled {
return t
}
switch c.theme {
switch c.Theme {
case DarkTheme:
return darkThemeTableHeader(t)
case LightTheme:

View file

@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/assert"
)
func TestColorFromRGB(t *testing.T) {
func TestLabel(t *testing.T) {
tests := []struct {
name string
hex string
@ -20,77 +20,57 @@ func TestColorFromRGB(t *testing.T) {
hex: "fc0303",
text: "red",
wants: "\033[38;2;252;3;3mred\033[0m",
cs: NewColorScheme(true, true, true, false, NoTheme),
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
TrueColor: true,
ColorLabels: true,
},
},
{
name: "no truecolor",
hex: "fc0303",
text: "red",
wants: "red",
cs: NewColorScheme(true, true, false, false, NoTheme),
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
ColorLabels: true,
},
},
{
name: "no color",
hex: "fc0303",
text: "red",
wants: "red",
cs: NewColorScheme(false, false, false, false, NoTheme),
cs: &ColorScheme{
ColorLabels: true,
},
},
{
name: "invalid hex",
hex: "fc0",
text: "red",
wants: "red",
cs: NewColorScheme(false, false, false, false, NoTheme),
cs: &ColorScheme{
ColorLabels: true,
},
},
{
name: "no color labels",
hex: "fc0303",
text: "red",
wants: "red",
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
ColorLabels: true,
},
},
}
for _, tt := range tests {
fn := tt.cs.ColorFromRGB(tt.hex)
assert.Equal(t, tt.wants, fn(tt.text))
}
}
func TestHexToRGB(t *testing.T) {
tests := []struct {
name string
hex string
text string
wants string
cs *ColorScheme
}{
{
name: "truecolor",
hex: "fc0303",
text: "red",
wants: "\033[38;2;252;3;3mred\033[0m",
cs: NewColorScheme(true, true, true, false, NoTheme),
},
{
name: "no truecolor",
hex: "fc0303",
text: "red",
wants: "red",
cs: NewColorScheme(true, true, false, false, NoTheme),
},
{
name: "no color",
hex: "fc0303",
text: "red",
wants: "red",
cs: NewColorScheme(false, false, false, false, NoTheme),
},
{
name: "invalid hex",
hex: "fc0",
text: "red",
wants: "red",
cs: NewColorScheme(false, false, false, false, NoTheme),
},
}
for _, tt := range tests {
output := tt.cs.HexToRGB(tt.hex, tt.text)
output := tt.cs.Label(tt.hex, tt.text)
assert.Equal(t, tt.wants, output)
}
}
@ -108,62 +88,110 @@ func TestTableHeader(t *testing.T) {
expected string
}{
{
name: "when color is disabled, text is not stylized",
cs: NewColorScheme(false, false, false, true, NoTheme),
name: "when color is disabled, text is not stylized",
cs: &ColorScheme{
Accessible: true,
Theme: NoTheme,
},
input: "this should not be stylized",
expected: "this should not be stylized",
},
{
name: "when 4-bit color is enabled but no theme, 4-bit default color and underline are used",
cs: NewColorScheme(true, false, false, true, NoTheme),
name: "when 4-bit color is enabled but no theme, 4-bit default color and underline are used",
cs: &ColorScheme{
Enabled: true,
Accessible: true,
Theme: NoTheme,
},
input: "this should have no explicit color but underlined",
expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset),
},
{
name: "when 4-bit color is enabled and theme is light, 4-bit dark color and underline are used",
cs: NewColorScheme(true, false, false, true, LightTheme),
name: "when 4-bit color is enabled and theme is light, 4-bit dark color and underline are used",
cs: &ColorScheme{
Enabled: true,
Accessible: true,
Theme: LightTheme,
},
input: "this should have dark foreground color and underlined",
expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset),
},
{
name: "when 4-bit color is enabled and theme is dark, 4-bit light color and underline are used",
cs: NewColorScheme(true, false, false, true, DarkTheme),
name: "when 4-bit color is enabled and theme is dark, 4-bit light color and underline are used",
cs: &ColorScheme{
Enabled: true,
Accessible: true,
Theme: DarkTheme,
},
input: "this should have light foreground color and underlined",
expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset),
},
{
name: "when 8-bit color is enabled but no theme, 4-bit default color and underline are used",
cs: NewColorScheme(true, true, false, true, NoTheme),
name: "when 8-bit color is enabled but no theme, 4-bit default color and underline are used",
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
Accessible: true,
Theme: NoTheme,
},
input: "this should have no explicit color but underlined",
expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset),
},
{
name: "when 8-bit color is enabled and theme is light, 4-bit dark color and underline are used",
cs: NewColorScheme(true, true, false, true, LightTheme),
name: "when 8-bit color is enabled and theme is light, 4-bit dark color and underline are used",
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
Accessible: true,
Theme: LightTheme,
},
input: "this should have dark foreground color and underlined",
expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset),
},
{
name: "when 8-bit color is true and theme is dark, 4-bit light color and underline are used",
cs: NewColorScheme(true, true, false, true, DarkTheme),
name: "when 8-bit color is true and theme is dark, 4-bit light color and underline are used",
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
Accessible: true,
Theme: DarkTheme,
},
input: "this should have light foreground color and underlined",
expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset),
},
{
name: "when 24-bit color is enabled but no theme, 4-bit default color and underline are used",
cs: NewColorScheme(true, true, true, true, NoTheme),
name: "when 24-bit color is enabled but no theme, 4-bit default color and underline are used",
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
TrueColor: true,
Accessible: true,
Theme: NoTheme,
},
input: "this should have no explicit color but underlined",
expected: fmt.Sprintf("%sthis should have no explicit color but underlined%s", defaultUnderline, reset),
},
{
name: "when 24-bit color is enabled and theme is light, 4-bit dark color and underline are used",
cs: NewColorScheme(true, true, true, true, LightTheme),
name: "when 24-bit color is enabled and theme is light, 4-bit dark color and underline are used",
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
TrueColor: true,
Accessible: true,
Theme: LightTheme,
},
input: "this should have dark foreground color and underlined",
expected: fmt.Sprintf("%sthis should have dark foreground color and underlined%s", brightBlackUnderline, reset),
},
{
name: "when 24-bit color is true and theme is dark, 4-bit light color and underline are used",
cs: NewColorScheme(true, true, true, true, DarkTheme),
name: "when 24-bit color is true and theme is dark, 4-bit light color and underline are used",
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
TrueColor: true,
Accessible: true,
Theme: DarkTheme,
},
input: "this should have light foreground color and underlined",
expected: fmt.Sprintf("%sthis should have light foreground color and underlined%s", dimBlackUnderline, reset),
},
@ -191,43 +219,70 @@ func TestMuted(t *testing.T) {
}{
{
name: "when color is disabled but accessible colors are disabled, text is not stylized",
cs: NewColorScheme(false, false, false, false, NoTheme),
cs: &ColorScheme{},
input: "this should not be stylized",
expected: "this should not be stylized",
},
{
name: "when 4-bit color is enabled but accessible colors are disabled, legacy 4-bit gray color is used",
cs: NewColorScheme(true, false, false, false, NoTheme),
name: "when 4-bit color is enabled but accessible colors are disabled, legacy 4-bit gray color is used",
cs: &ColorScheme{
Enabled: true,
},
input: "this should be 4-bit gray",
expected: fmt.Sprintf("%sthis should be 4-bit gray%s", gray4bit, reset),
},
{
name: "when 8-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used",
cs: NewColorScheme(true, true, false, false, NoTheme),
name: "when 8-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used",
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
},
input: "this should be 8-bit gray",
expected: fmt.Sprintf("%sthis should be 8-bit gray%s", gray8bit, reset),
},
{
name: "when 24-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used",
cs: NewColorScheme(true, true, true, false, NoTheme),
name: "when 24-bit color is enabled but accessible colors are disabled, legacy 8-bit gray color is used",
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
TrueColor: true,
},
input: "this should be 8-bit gray",
expected: fmt.Sprintf("%sthis should be 8-bit gray%s", gray8bit, reset),
},
{
name: "when 4-bit color is enabled and theme is dark, 4-bit light color is used",
cs: NewColorScheme(true, true, true, true, DarkTheme),
name: "when 4-bit color is enabled and theme is dark, 4-bit light color is used",
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
TrueColor: true,
Accessible: true,
Theme: DarkTheme,
},
input: "this should be 4-bit dim black",
expected: fmt.Sprintf("%sthis should be 4-bit dim black%s", dimBlack4bit, reset),
},
{
name: "when 4-bit color is enabled and theme is light, 4-bit dark color is used",
cs: NewColorScheme(true, true, true, true, LightTheme),
name: "when 4-bit color is enabled and theme is light, 4-bit dark color is used",
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
TrueColor: true,
Accessible: true,
Theme: LightTheme,
},
input: "this should be 4-bit bright black",
expected: fmt.Sprintf("%sthis should be 4-bit bright black%s", brightBlack4bit, reset),
},
{
name: "when 4-bit color is enabled but no theme, 4-bit default color is used",
cs: NewColorScheme(true, true, true, true, NoTheme),
name: "when 4-bit color is enabled but no theme, 4-bit default color is used",
cs: &ColorScheme{
Enabled: true,
EightBitColor: true,
TrueColor: true,
Accessible: true,
Theme: NoTheme,
},
input: "this should have no explicit color",
expected: "this should have no explicit color",
},

View file

@ -5,7 +5,7 @@ package iostreams
import "os"
func hasAlternateScreenBuffer(hasTrueColor bool) bool {
func hasAlternateScreenBuffer(_ bool) bool {
// on non-Windows, we just assume that alternate screen buffer is supported in most cases
return os.Getenv("TERM") != "dumb"
}

View file

@ -72,12 +72,14 @@ type IOStreams struct {
colorOverride bool
colorEnabled bool
colorLabels bool
accessibleColorsEnabled bool
pagerCommand string
pagerProcess *os.Process
neverPrompt bool
neverPrompt bool
spinnerDisabled bool
TempFileOverride *os.File
}
@ -103,6 +105,10 @@ func (s *IOStreams) HasTrueColor() bool {
return s.term.IsTrueColorSupported()
}
func (s *IOStreams) ColorLabels() bool {
return s.colorLabels
}
// DetectTerminalTheme is a utility to call before starting the output pager so that the terminal background
// can be reliably detected.
func (s *IOStreams) DetectTerminalTheme() {
@ -135,6 +141,10 @@ func (s *IOStreams) SetColorEnabled(colorEnabled bool) {
s.colorEnabled = colorEnabled
}
func (s *IOStreams) SetColorLabels(colorLabels bool) {
s.colorLabels = colorLabels
}
func (s *IOStreams) SetStdinTTY(isTTY bool) {
s.stdinTTYOverride = true
s.stdinIsTTY = isTTY
@ -264,6 +274,14 @@ func (s *IOStreams) SetNeverPrompt(v bool) {
s.neverPrompt = v
}
func (s *IOStreams) GetSpinnerDisabled() bool {
return s.spinnerDisabled
}
func (s *IOStreams) SetSpinnerDisabled(v bool) {
s.spinnerDisabled = v
}
func (s *IOStreams) StartProgressIndicator() {
s.StartProgressIndicatorWithLabel("")
}
@ -273,6 +291,15 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) {
return
}
if s.spinnerDisabled {
// If the spinner is disabled, simply print a
// textual progress indicator and return.
// This means that s.ProgressIndicator will be nil.
// See also: the comment on StopProgressIndicator()
s.startTextualProgressIndicator(label)
return
}
s.progressIndicatorMu.Lock()
defer s.progressIndicatorMu.Unlock()
@ -286,8 +313,10 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) {
}
// https://github.com/briandowns/spinner#available-character-sets
dotStyle := spinner.CharSets[11]
sp := spinner.New(dotStyle, 120*time.Millisecond, spinner.WithWriter(s.ErrOut), spinner.WithColor("fgCyan"))
// ⣾ ⣷ ⣽ ⣻ ⡿
spinnerStyle := spinner.CharSets[11]
sp := spinner.New(spinnerStyle, 120*time.Millisecond, spinner.WithWriter(s.ErrOut), spinner.WithColor("fgCyan"))
if label != "" {
sp.Prefix = label + " "
}
@ -296,6 +325,27 @@ func (s *IOStreams) StartProgressIndicatorWithLabel(label string) {
s.progressIndicator = sp
}
func (s *IOStreams) startTextualProgressIndicator(label string) {
s.progressIndicatorMu.Lock()
defer s.progressIndicatorMu.Unlock()
// Default label when spinner disabled is "Working..."
if label == "" {
label = "Working..."
}
// Add an ellipsis to the label if it doesn't already have one.
ellipsis := "..."
if !strings.HasSuffix(label, ellipsis) {
label = label + ellipsis
}
fmt.Fprintf(s.ErrOut, "%s%s", s.ColorScheme().Cyan(label), "\n")
}
// StopProgressIndicator stops the progress indicator if it is running.
// Note that a textual progess indicator does not create a progress indicator,
// so this method is a no-op in that case.
func (s *IOStreams) StopProgressIndicator() {
s.progressIndicatorMu.Lock()
defer s.progressIndicatorMu.Unlock()
@ -367,7 +417,14 @@ func (s *IOStreams) TerminalWidth() int {
}
func (s *IOStreams) ColorScheme() *ColorScheme {
return NewColorScheme(s.ColorEnabled(), s.ColorSupport256(), s.HasTrueColor(), s.AccessibleColorsEnabled(), s.TerminalTheme())
return &ColorScheme{
Enabled: s.ColorEnabled(),
EightBitColor: s.ColorSupport256(),
TrueColor: s.HasTrueColor(),
Accessible: s.AccessibleColorsEnabled(),
ColorLabels: s.ColorLabels(),
Theme: s.TerminalTheme(),
}
}
func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) {

View file

@ -0,0 +1,254 @@
//go:build !windows
package iostreams
import (
"fmt"
"io"
"os"
"strings"
"testing"
"time"
"github.com/Netflix/go-expect"
"github.com/creack/pty"
"github.com/hinshun/vt10x"
"github.com/stretchr/testify/require"
)
func TestStartProgressIndicatorWithLabel(t *testing.T) {
osOut := os.Stdout
defer func() { os.Stdout = osOut }()
// Why do we need a channel in these tests to implement a timeout instead of
// relying on expect's timeout?
//
// Well, expect's timeout is based on the maximum time of a single read
// from the console. This works in cases like prompting where we block
// waiting for input because the console is not ready to be read.
// But in this case, we are not blocking waiting for input and stdout
// can be constantly read. This means the timeout will never be reached
// in the event of a expectation failure.
// To fix this, we need to implement our own timeout that is based
// specifically on the total time spent reading the console and waiting
// for the target string instead of the max time for a single read
// from the console.
t.Run("progress indicator respects GH_SPINNER_DISABLED is true", func(t *testing.T) {
console := newTestVirtualTerminal(t)
io := newTestIOStreams(t, console, true)
done := make(chan error)
go func() {
_, err := console.ExpectString("Working...")
done <- err
}()
io.StartProgressIndicatorWithLabel("")
defer io.StopProgressIndicator()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("Test timed out waiting for progress indicator")
}
})
t.Run("progress indicator respects GH_SPINNER_DISABLED is false", func(t *testing.T) {
console := newTestVirtualTerminal(t)
io := newTestIOStreams(t, console, false)
done := make(chan error)
go func() {
_, err := console.ExpectString("⣾")
done <- err
}()
io.StartProgressIndicatorWithLabel("")
defer io.StopProgressIndicator()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("Test timed out waiting for progress indicator")
}
})
t.Run("progress indicator with GH_SPINNER_DISABLED shows label", func(t *testing.T) {
console := newTestVirtualTerminal(t)
io := newTestIOStreams(t, console, true)
progressIndicatorLabel := "downloading happiness"
done := make(chan error)
go func() {
_, err := console.ExpectString(progressIndicatorLabel + "...")
done <- err
}()
io.StartProgressIndicatorWithLabel(progressIndicatorLabel)
defer io.StopProgressIndicator()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("Test timed out waiting for progress indicator")
}
})
t.Run("progress indicator shows label and spinner", func(t *testing.T) {
console := newTestVirtualTerminal(t)
io := newTestIOStreams(t, console, false)
progressIndicatorLabel := "downloading happiness"
done := make(chan error)
go func() {
_, err := console.ExpectString(progressIndicatorLabel)
require.NoError(t, err)
_, err = console.ExpectString("⣾")
done <- err
}()
io.StartProgressIndicatorWithLabel(progressIndicatorLabel)
defer io.StopProgressIndicator()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("Test timed out waiting for progress indicator")
}
})
t.Run("multiple calls to start progress indicator with GH_SPINNER_DISABLED prints additional labels", func(t *testing.T) {
console := newTestVirtualTerminal(t)
io := newTestIOStreams(t, console, true)
progressIndicatorLabel1 := "downloading happiness"
progressIndicatorLabel2 := "downloading sadness"
done := make(chan error)
go func() {
_, err := console.ExpectString(progressIndicatorLabel1 + "...")
require.NoError(t, err)
_, err = console.ExpectString(progressIndicatorLabel2 + "...")
done <- err
}()
io.StartProgressIndicatorWithLabel(progressIndicatorLabel1)
defer io.StopProgressIndicator()
io.StartProgressIndicatorWithLabel(progressIndicatorLabel2)
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("Test timed out waiting for progress indicator")
}
})
}
func newTestVirtualTerminal(t *testing.T) *expect.Console {
t.Helper()
// Create a PTY and hook up a virtual terminal emulator
ptm, pts, err := pty.Open()
require.NoError(t, err)
term := vt10x.New(vt10x.WithWriter(pts))
// Create a console via Expect that allows scripting against the terminal
consoleOpts := []expect.ConsoleOpt{
expect.WithStdin(ptm),
expect.WithStdout(term),
expect.WithCloser(ptm, pts),
failOnExpectError(t),
failOnSendError(t),
expect.WithDefaultTimeout(time.Second),
}
console, err := expect.NewConsole(consoleOpts...)
require.NoError(t, err)
t.Cleanup(func() { testCloser(t, console) })
return console
}
func newTestIOStreams(t *testing.T, console *expect.Console, spinnerDisabled bool) *IOStreams {
t.Helper()
in := console.Tty()
out := console.Tty()
errOut := console.Tty()
// Because the briandowns/spinner checks os.Stdout directly,
// we need this hack to trick it into allowing the spinner to print...
os.Stdout = out
io := &IOStreams{
In: in,
Out: out,
ErrOut: errOut,
term: fakeTerm{},
}
io.progressIndicatorEnabled = true
io.SetSpinnerDisabled(spinnerDisabled)
return io
}
// failOnExpectError adds an observer that will fail the test in a standardised way
// if any expectation on the command output fails, without requiring an explicit
// assertion.
//
// Use WithRelaxedIO to disable this behaviour.
func failOnExpectError(t *testing.T) expect.ConsoleOpt {
t.Helper()
return expect.WithExpectObserver(
func(matchers []expect.Matcher, buf string, err error) {
t.Helper()
if err == nil {
return
}
if len(matchers) == 0 {
t.Fatalf("Error occurred while matching %q: %s\n", buf, err)
}
var criteria []string
for _, matcher := range matchers {
criteria = append(criteria, fmt.Sprintf("%q", matcher.Criteria()))
}
t.Fatalf("Failed to find [%s] in %q: %s\n", strings.Join(criteria, ", "), buf, err)
},
)
}
// failOnSendError adds an observer that will fail the test in a standardised way
// if any sending of input fails, without requiring an explicit assertion.
//
// Use WithRelaxedIO to disable this behaviour.
func failOnSendError(t *testing.T) expect.ConsoleOpt {
t.Helper()
return expect.WithSendObserver(
func(msg string, n int, err error) {
t.Helper()
if err != nil {
t.Fatalf("Failed to send %q: %s\n", msg, err)
}
if len(msg) != n {
t.Fatalf("Only sent %d of %d bytes for %q\n", n, len(msg), msg)
}
},
)
}
// testCloser is a helper to fail the test if a Closer fails to close.
func testCloser(t *testing.T, closer io.Closer) {
t.Helper()
if err := closer.Close(); err != nil {
t.Errorf("Close failed: %s", err)
}
}

View file

@ -46,6 +46,15 @@ func None[T any]() Option[T] {
return Option[T]{}
}
func SomeIfNonZero[T comparable](value T) Option[T] {
// value is a zero value then return a None
var zero T
if value == zero {
return None[T]()
}
return Some(value)
}
// String implements the [fmt.Stringer] interface.
func (o Option[T]) String() string {
if o.present {

View file

@ -93,25 +93,29 @@ var PullRequestFields = append(IssueFields,
type CodeResult struct {
IncompleteResults bool `json:"incomplete_results"`
Items []Code `json:"items"`
Total int `json:"total_count"`
// Number of code search results matching the query on the server. Ignoring limit.
Total int `json:"total_count"`
}
type CommitsResult struct {
IncompleteResults bool `json:"incomplete_results"`
Items []Commit `json:"items"`
Total int `json:"total_count"`
// Number of commits matching the query on the server. Ignoring limit.
Total int `json:"total_count"`
}
type RepositoriesResult struct {
IncompleteResults bool `json:"incomplete_results"`
Items []Repository `json:"items"`
Total int `json:"total_count"`
// Number of repositories matching the query on the server. Ignoring limit.
Total int `json:"total_count"`
}
type IssuesResult struct {
IncompleteResults bool `json:"incomplete_results"`
Items []Issue `json:"items"`
Total int `json:"total_count"`
// Number of isssues matching the query on the server. Ignoring limit.
Total int `json:"total_count"`
}
type Code struct {

View file

@ -14,6 +14,7 @@ import (
)
const (
// GitHub API has a limit of 100 per page
maxPerPage = 100
orderKey = "order"
sortKey = "sort"
@ -60,100 +61,145 @@ func NewSearcher(client *http.Client, host string) Searcher {
func (s searcher) Code(query Query) (CodeResult, error) {
result := CodeResult{}
toRetrieve := query.Limit
var resp *http.Response
var err error
for toRetrieve > 0 {
query.Limit = min(toRetrieve, maxPerPage)
// We will request either the query limit if it's less than 1 page, or our max page size.
// This number doesn't change to keep a valid offset.
//
// For example, say we want 150 items out of 500.
// We request page #1 for 100 items and get items 0 to 99.
// Then we request page #2 for 100 items, we get items 100 to 199 and only keep 100 to 149.
// If we were to request page #2 for 50 items, we would instead get items 50 to 99.
numItemsToRetrieve := query.Limit
query.Limit = min(numItemsToRetrieve, maxPerPage)
for numItemsToRetrieve > 0 {
query.Page = nextPage(resp)
if query.Page == 0 {
break
}
page := CodeResult{}
resp, err = s.search(query, &page)
if err != nil {
return result, err
}
// If we're going to reach the requested limit, only add that many items,
// otherwise add all the results.
numItemsToAdd := min(len(page.Items), numItemsToRetrieve)
result.IncompleteResults = page.IncompleteResults
// The API returns how many items match the query in every response.
// With the example above, this would be 500.
result.Total = page.Total
result.Items = append(result.Items, page.Items...)
toRetrieve = toRetrieve - len(page.Items)
result.Items = append(result.Items, page.Items[:numItemsToAdd]...)
numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd
}
return result, nil
}
func (s searcher) Commits(query Query) (CommitsResult, error) {
result := CommitsResult{}
toRetrieve := query.Limit
var resp *http.Response
var err error
for toRetrieve > 0 {
query.Limit = min(toRetrieve, maxPerPage)
numItemsToRetrieve := query.Limit
query.Limit = min(numItemsToRetrieve, maxPerPage)
for numItemsToRetrieve > 0 {
query.Page = nextPage(resp)
if query.Page == 0 {
break
}
page := CommitsResult{}
resp, err = s.search(query, &page)
if err != nil {
return result, err
}
numItemsToAdd := min(len(page.Items), numItemsToRetrieve)
result.IncompleteResults = page.IncompleteResults
result.Total = page.Total
result.Items = append(result.Items, page.Items...)
toRetrieve = toRetrieve - len(page.Items)
result.Items = append(result.Items, page.Items[:numItemsToAdd]...)
numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd
}
return result, nil
}
func (s searcher) Repositories(query Query) (RepositoriesResult, error) {
result := RepositoriesResult{}
toRetrieve := query.Limit
var resp *http.Response
var err error
for toRetrieve > 0 {
query.Limit = min(toRetrieve, maxPerPage)
numItemsToRetrieve := query.Limit
query.Limit = min(numItemsToRetrieve, maxPerPage)
for numItemsToRetrieve > 0 {
query.Page = nextPage(resp)
if query.Page == 0 {
break
}
page := RepositoriesResult{}
resp, err = s.search(query, &page)
if err != nil {
return result, err
}
numItemsToAdd := min(len(page.Items), numItemsToRetrieve)
result.IncompleteResults = page.IncompleteResults
result.Total = page.Total
result.Items = append(result.Items, page.Items...)
toRetrieve = toRetrieve - len(page.Items)
result.Items = append(result.Items, page.Items[:numItemsToAdd]...)
numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd
}
return result, nil
}
func (s searcher) Issues(query Query) (IssuesResult, error) {
result := IssuesResult{}
toRetrieve := query.Limit
var resp *http.Response
var err error
for toRetrieve > 0 {
query.Limit = min(toRetrieve, maxPerPage)
numItemsToRetrieve := query.Limit
query.Limit = min(numItemsToRetrieve, maxPerPage)
for numItemsToRetrieve > 0 {
query.Page = nextPage(resp)
if query.Page == 0 {
break
}
page := IssuesResult{}
resp, err = s.search(query, &page)
if err != nil {
return result, err
}
numItemsToAdd := min(len(page.Items), numItemsToRetrieve)
result.IncompleteResults = page.IncompleteResults
result.Total = page.Total
result.Items = append(result.Items, page.Items...)
toRetrieve = toRetrieve - len(page.Items)
result.Items = append(result.Items, page.Items[:numItemsToAdd]...)
numItemsToRetrieve = numItemsToRetrieve - numItemsToAdd
}
return result, nil
}
// search makes a single-page REST search request for code, commits, issues, prs, or repos.
//
// The result argument is populated with the following information:
//
// - Total: the number of search results matching the query, which may exceed the number of items returned
// - IncompleteResults: whether the search request exceeded search time limit, potentially being incomplete
// - Items: the actual matching search results, up to 100 max items per page
//
// For more information, see https://docs.github.com/en/rest/search/search?apiVersion=2022-11-28.
func (s searcher) search(query Query, result interface{}) (*http.Response, error) {
path := fmt.Sprintf("%ssearch/%s", ghinstance.RESTPrefix(s.host), query.Kind)
qs := url.Values{}
@ -236,10 +282,15 @@ func handleHTTPError(resp *http.Response) error {
return httpError
}
// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
func nextPage(resp *http.Response) (page int) {
if resp == nil {
return 1
}
// When using pagination, responses get a "Link" field in their header.
// When a next page is available, "Link" contains a link to the next page
// tagged with rel="next".
for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) {
if !(len(m) > 2 && m[2] == "next") {
continue

View file

@ -1,8 +1,10 @@
package search
import (
"fmt"
"net/http"
"net/url"
"strconv"
"testing"
"github.com/MakeNowJust/heredoc"
@ -46,10 +48,14 @@ func TestSearcherCode(t *testing.T) {
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "search/code", values),
httpmock.JSONResponse(CodeResult{
IncompleteResults: false,
Items: []Code{{Name: "file.go"}},
Total: 1,
httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 1,
"items": []interface{}{
map[string]interface{}{
"name": "file.go",
},
},
}),
)
},
@ -66,10 +72,14 @@ func TestSearcherCode(t *testing.T) {
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "api/v3/search/code", values),
httpmock.JSONResponse(CodeResult{
IncompleteResults: false,
Items: []Code{{Name: "file.go"}},
Total: 1,
httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 1,
"items": []interface{}{
map[string]interface{}{
"name": "file.go",
},
},
}),
)
},
@ -84,25 +94,83 @@ func TestSearcherCode(t *testing.T) {
},
httpStubs: func(reg *httpmock.Registry) {
firstReq := httpmock.QueryMatcher("GET", "search/code", values)
firstRes := httpmock.JSONResponse(CodeResult{
IncompleteResults: false,
Items: []Code{{Name: "file.go"}},
Total: 2,
firstRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 2,
"items": []interface{}{
map[string]interface{}{
"name": "file.go",
},
},
})
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/code?page=2&per_page=30&q=org%3Agithub>; rel="next"`)
secondReq := httpmock.QueryMatcher("GET", "search/code", url.Values{
"page": []string{"2"},
"per_page": []string{"30"},
"q": []string{"keyword language:go"},
})
secondRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 2,
"items": []interface{}{
map[string]interface{}{
"name": "file2.go",
},
},
})
reg.Register(firstReq, firstRes)
reg.Register(secondReq, secondRes)
},
},
{
name: "collect full and partial pages under total number of matching search results",
query: Query{
Keywords: []string{"keyword"},
Kind: "code",
Limit: 110,
Qualifiers: Qualifiers{
Language: "go",
},
)
},
result: CodeResult{
IncompleteResults: false,
Items: initialize(0, 110, func(i int) Code {
return Code{
Name: fmt.Sprintf("name%d.go", i),
}
}),
Total: 287,
},
httpStubs: func(reg *httpmock.Registry) {
firstReq := httpmock.QueryMatcher("GET", "search/code", url.Values{
"page": []string{"1"},
"per_page": []string{"100"},
"q": []string{"keyword language:go"},
})
firstRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 287,
"items": initialize(0, 100, func(i int) interface{} {
return map[string]interface{}{
"name": fmt.Sprintf("name%d.go", i),
}
}),
})
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/code?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
secondReq := httpmock.QueryMatcher("GET", "search/code", url.Values{
"page": []string{"2"},
"per_page": []string{"29"},
"per_page": []string{"100"},
"q": []string{"keyword language:go"},
},
)
secondRes := httpmock.JSONResponse(CodeResult{
IncompleteResults: false,
Items: []Code{{Name: "file2.go"}},
Total: 2,
},
)
})
secondRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 287,
"items": initialize(100, 200, func(i int) interface{} {
return map[string]interface{}{
"name": fmt.Sprintf("name%d.go", i),
}
}),
})
reg.Register(firstReq, firstRes)
reg.Register(secondReq, secondRes)
},
@ -201,10 +269,14 @@ func TestSearcherCommits(t *testing.T) {
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "search/commits", values),
httpmock.JSONResponse(CommitsResult{
IncompleteResults: false,
Items: []Commit{{Sha: "abc"}},
Total: 1,
httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 1,
"items": []interface{}{
map[string]interface{}{
"sha": "abc",
},
},
}),
)
},
@ -221,10 +293,14 @@ func TestSearcherCommits(t *testing.T) {
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "api/v3/search/commits", values),
httpmock.JSONResponse(CommitsResult{
IncompleteResults: false,
Items: []Commit{{Sha: "abc"}},
Total: 1,
httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 1,
"items": []interface{}{
map[string]interface{}{
"sha": "abc",
},
},
}),
)
},
@ -239,27 +315,92 @@ func TestSearcherCommits(t *testing.T) {
},
httpStubs: func(reg *httpmock.Registry) {
firstReq := httpmock.QueryMatcher("GET", "search/commits", values)
firstRes := httpmock.JSONResponse(CommitsResult{
IncompleteResults: false,
Items: []Commit{{Sha: "abc"}},
Total: 2,
},
)
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/commits?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
firstRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 2,
"items": []interface{}{
map[string]interface{}{
"sha": "abc",
},
},
})
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/commits?page=2&per_page=30&q=org%3Agithub>; rel="next"`)
secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{
"page": []string{"2"},
"per_page": []string{"29"},
"per_page": []string{"30"},
"order": []string{"desc"},
"sort": []string{"committer-date"},
"q": []string{"keyword author:foobar committer-date:>2021-02-28"},
})
secondRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 2,
"items": []interface{}{
map[string]interface{}{
"sha": "def",
},
},
})
reg.Register(firstReq, firstRes)
reg.Register(secondReq, secondRes)
},
},
{
name: "collect full and partial pages under total number of matching search results",
query: Query{
Keywords: []string{"keyword"},
Kind: "commits",
Limit: 110,
Order: "desc",
Sort: "committer-date",
Qualifiers: Qualifiers{
Author: "foobar",
CommitterDate: ">2021-02-28",
},
)
secondRes := httpmock.JSONResponse(CommitsResult{
IncompleteResults: false,
Items: []Commit{{Sha: "def"}},
Total: 2,
},
)
},
result: CommitsResult{
IncompleteResults: false,
Items: initialize(0, 110, func(i int) Commit {
return Commit{
Sha: strconv.Itoa(i),
}
}),
Total: 287,
},
httpStubs: func(reg *httpmock.Registry) {
firstReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{
"page": []string{"1"},
"per_page": []string{"100"},
"order": []string{"desc"},
"sort": []string{"committer-date"},
"q": []string{"keyword author:foobar committer-date:>2021-02-28"},
})
firstRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 287,
"items": initialize(0, 100, func(i int) map[string]interface{} {
return map[string]interface{}{
"sha": strconv.Itoa(i),
}
}),
})
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/commits?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{
"page": []string{"2"},
"per_page": []string{"100"},
"order": []string{"desc"},
"sort": []string{"committer-date"},
"q": []string{"keyword author:foobar committer-date:>2021-02-28"},
})
secondRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 287,
"items": initialize(100, 200, func(i int) map[string]interface{} {
return map[string]interface{}{
"sha": strconv.Itoa(i),
}
}),
})
reg.Register(firstReq, firstRes)
reg.Register(secondReq, secondRes)
},
@ -269,8 +410,8 @@ func TestSearcherCommits(t *testing.T) {
query: query,
wantErr: true,
errMsg: heredoc.Doc(`
Invalid search query "keyword author:foobar committer-date:>2021-02-28".
"blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
Invalid search query "keyword author:foobar committer-date:>2021-02-28".
"blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "search/commits", values),
@ -413,15 +554,14 @@ func TestSearcherRepositories(t *testing.T) {
},
},
})
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/repositories?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/repositories?page=2&per_page=30&q=org%3Agithub>; rel="next"`)
secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{
"page": []string{"2"},
"per_page": []string{"29"},
"per_page": []string{"30"},
"order": []string{"desc"},
"sort": []string{"stars"},
"q": []string{"keyword stars:>=5 topic:topic"},
},
)
})
secondRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 2,
@ -435,13 +575,73 @@ func TestSearcherRepositories(t *testing.T) {
reg.Register(secondReq, secondRes)
},
},
{
name: "collect full and partial pages under total number of matching search results",
query: Query{
Keywords: []string{"keyword"},
Kind: "repositories",
Limit: 110,
Order: "desc",
Sort: "stars",
Qualifiers: Qualifiers{
Stars: ">=5",
Topic: []string{"topic"},
},
},
result: RepositoriesResult{
IncompleteResults: false,
Items: initialize(0, 110, func(i int) Repository {
return Repository{
Name: fmt.Sprintf("name%d", i),
}
}),
Total: 287,
},
httpStubs: func(reg *httpmock.Registry) {
firstReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{
"page": []string{"1"},
"per_page": []string{"100"},
"order": []string{"desc"},
"sort": []string{"stars"},
"q": []string{"keyword stars:>=5 topic:topic"},
})
firstRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 287,
"items": initialize(0, 100, func(i int) interface{} {
return map[string]interface{}{
"name": fmt.Sprintf("name%d", i),
}
}),
})
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/repositories?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{
"page": []string{"2"},
"per_page": []string{"100"},
"order": []string{"desc"},
"sort": []string{"stars"},
"q": []string{"keyword stars:>=5 topic:topic"},
})
secondRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 287,
"items": initialize(100, 200, func(i int) interface{} {
return map[string]interface{}{
"name": fmt.Sprintf("name%d", i),
}
}),
})
reg.Register(firstReq, firstRes)
reg.Register(secondReq, secondRes)
},
},
{
name: "handles search errors",
query: query,
wantErr: true,
errMsg: heredoc.Doc(`
Invalid search query "keyword stars:>=5 topic:topic".
"blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
Invalid search query "keyword stars:>=5 topic:topic".
"blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "search/repositories", values),
@ -529,10 +729,14 @@ func TestSearcherIssues(t *testing.T) {
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "search/issues", values),
httpmock.JSONResponse(IssuesResult{
IncompleteResults: false,
Items: []Issue{{Number: 1234}},
Total: 1,
httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 1,
"items": []interface{}{
map[string]interface{}{
"number": 1234,
},
},
}),
)
},
@ -549,10 +753,14 @@ func TestSearcherIssues(t *testing.T) {
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "api/v3/search/issues", values),
httpmock.JSONResponse(IssuesResult{
IncompleteResults: false,
Items: []Issue{{Number: 1234}},
Total: 1,
httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 1,
"items": []interface{}{
map[string]interface{}{
"number": 1234,
},
},
}),
)
},
@ -567,27 +775,92 @@ func TestSearcherIssues(t *testing.T) {
},
httpStubs: func(reg *httpmock.Registry) {
firstReq := httpmock.QueryMatcher("GET", "search/issues", values)
firstRes := httpmock.JSONResponse(IssuesResult{
IncompleteResults: false,
Items: []Issue{{Number: 1234}},
Total: 2,
},
)
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/issues?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
firstRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 2,
"items": []interface{}{
map[string]interface{}{
"number": 1234,
},
},
})
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/issues?page=2&per_page=30&q=org%3Agithub>; rel="next"`)
secondReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{
"page": []string{"2"},
"per_page": []string{"29"},
"per_page": []string{"30"},
"order": []string{"desc"},
"sort": []string{"comments"},
"q": []string{"keyword is:locked is:public language:go"},
})
secondRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 2,
"items": []interface{}{
map[string]interface{}{
"number": 5678,
},
},
})
reg.Register(firstReq, firstRes)
reg.Register(secondReq, secondRes)
},
},
{
name: "collect full and partial pages under total number of matching search results",
query: Query{
Keywords: []string{"keyword"},
Kind: "issues",
Limit: 110,
Order: "desc",
Sort: "comments",
Qualifiers: Qualifiers{
Language: "go",
Is: []string{"public", "locked"},
},
)
secondRes := httpmock.JSONResponse(IssuesResult{
IncompleteResults: false,
Items: []Issue{{Number: 5678}},
Total: 2,
},
)
},
result: IssuesResult{
IncompleteResults: false,
Items: initialize(0, 110, func(i int) Issue {
return Issue{
Number: i,
}
}),
Total: 287,
},
httpStubs: func(reg *httpmock.Registry) {
firstReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{
"page": []string{"1"},
"per_page": []string{"100"},
"order": []string{"desc"},
"sort": []string{"comments"},
"q": []string{"keyword is:locked is:public language:go"},
})
firstRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 287,
"items": initialize(0, 100, func(i int) interface{} {
return map[string]interface{}{
"number": i,
}
}),
})
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/issues?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
secondReq := httpmock.QueryMatcher("GET", "search/issues", url.Values{
"page": []string{"2"},
"per_page": []string{"100"},
"order": []string{"desc"},
"sort": []string{"comments"},
"q": []string{"keyword is:locked is:public language:go"},
})
secondRes := httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 287,
"items": initialize(100, 200, func(i int) interface{} {
return map[string]interface{}{
"number": i,
}
}),
})
reg.Register(firstReq, firstRes)
reg.Register(secondReq, secondRes)
},
@ -597,8 +870,8 @@ func TestSearcherIssues(t *testing.T) {
query: query,
wantErr: true,
errMsg: heredoc.Doc(`
Invalid search query "keyword is:locked is:public language:go".
"blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
Invalid search query "keyword is:locked is:public language:go".
"blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.QueryMatcher("GET", "search/issues", values),
@ -686,3 +959,12 @@ func TestSearcherURL(t *testing.T) {
})
}
}
// initialize generate slices over a range for test scenarios using the provided initializer.
func initialize[T any](start int, stop int, initializer func(i int) T) []T {
results := make([]T, 0, (stop - start))
for i := start; i < stop; i++ {
results = append(results, initializer(i))
}
return results
}