Merge branch 'trunk' into trunk
This commit is contained in:
commit
265139f268
93 changed files with 6101 additions and 1817 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
46
acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar
vendored
Normal file
46
acceptance/testdata/pr/pr-create-guesses-remote-from-sha.txtar
vendored
Normal 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}
|
||||
27
acceptance/testdata/pr/pr-create-no-local-repo.txtar
vendored
Normal file
27
acceptance/testdata/pr/pr-create-no-local-repo.txtar
vendored
Normal 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
|
||||
49
acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar
vendored
Normal file
49
acceptance/testdata/pr/pr-create-respects-branch-pushremote.txtar
vendored
Normal 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}
|
||||
53
acceptance/testdata/pr/pr-create-respects-push-destination.txtar
vendored
Normal file
53
acceptance/testdata/pr/pr-create-respects-push-destination.txtar
vendored
Normal 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}
|
||||
49
acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar
vendored
Normal file
49
acceptance/testdata/pr/pr-create-respects-remote-pushdefault.txtar
vendored
Normal 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}
|
||||
34
acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar
vendored
Normal file
34
acceptance/testdata/pr/pr-create-respects-simple-pushdefault.txtar
vendored
Normal 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}
|
||||
47
acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar
vendored
Normal file
47
acceptance/testdata/pr/pr-create-respects-user-colon-branch-syntax.txtar
vendored
Normal 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}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
46
acceptance/testdata/pr/pr-status-respects-cross-org.txtar
vendored
Normal file
46
acceptance/testdata/pr/pr-status-respects-cross-org.txtar
vendored
Normal 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'
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
79
go.mod
|
|
@ -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
355
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
493
internal/prompter/accessible_prompter_test.go
Normal file
493
internal/prompter/accessible_prompter_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 name—essentially 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 name—essentially 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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
394
pkg/cmd/pr/shared/find_refs_resolution.go
Normal file
394
pkg/cmd/pr/shared/find_refs_resolution.go
Normal 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
|
||||
}
|
||||
508
pkg/cmd/pr/shared/find_refs_resolution_test.go
Normal file
508
pkg/cmd/pr/shared/find_refs_resolution_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
18
pkg/cmd/pr/shared/git_cached_config_client.go
Normal file
18
pkg/cmd/pr/shared/git_cached_config_client.go
Normal 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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, ", ")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
`, "`"),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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 doesn’t 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
254
pkg/iostreams/iostreams_progress_indicator_test.go
Normal file
254
pkg/iostreams/iostreams_progress_indicator_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue